feat: add production health smoke

This commit is contained in:
732642856
2026-07-18 01:22:35 +08:00
parent 42e4e29ac1
commit 8f0cafbb67
3 changed files with 173 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
type Check = {
status: "ok" | "degraded" | "blocked";
message?: string;
latencyMs?: number;
};
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
function envCheck(names: string[]): Check {
const missing = names.filter((name) => !process.env[name]);
return missing.length
? { status: "blocked", message: `missing:${missing.join(",")}` }
: { status: "ok" };
}
function anyEnvCheck(names: string[]): Check {
return names.some((name) => process.env[name])
? { status: "ok" }
: { status: "blocked", message: `missing_one_of:${names.join("|")}` };
}
async function jyotishApiCheck(): Promise<Check> {
const started = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(`${jyotishApiBase}/api/health`, {
cache: "no-store",
signal: controller.signal,
});
return {
status: response.ok ? "ok" : "degraded",
message: response.ok ? undefined : `http:${response.status}`,
latencyMs: Date.now() - started,
};
} catch (error) {
return {
status: "blocked",
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
latencyMs: Date.now() - started,
};
} finally {
clearTimeout(timeout);
}
}
function aggregate(checks: Record<string, Check>) {
if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked";
if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded";
return "ok";
}
export async function GET() {
const checks = {
web: { status: "ok" } satisfies Check,
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
jyotishApi: await jyotishApiCheck(),
};
const status = aggregate(checks);
return NextResponse.json(
{
status,
timestamp: new Date().toISOString(),
checks,
},
{ status: status === "ok" ? 200 : 503 },
);
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Production smoke check for Jyotisha web deployment."""
from __future__ import annotations
import argparse
import json
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
def fetch(url: str, timeout: float) -> tuple[int, str, float]:
started = time.monotonic()
request = Request(url, headers={"User-Agent": "jyotisha-production-smoke/1.0"})
try:
with urlopen(request, timeout=timeout) as response:
return response.status, response.read().decode("utf-8", "replace"), time.monotonic() - started
except HTTPError as error:
return error.code, error.read().decode("utf-8", "replace"), time.monotonic() - started
except URLError as error:
raise RuntimeError(str(error.reason)) from error
def check(base_url: str, timeout: float) -> dict:
base = base_url.rstrip("/")
checks: list[dict] = []
status, body, elapsed = fetch(f"{base}/", timeout)
checks.append(
{
"name": "homepage",
"ok": status == 200 and ("Jyotisha" in body or "账户与出生资料" in body),
"status": status,
"latency_ms": round(elapsed * 1000),
}
)
status, body, elapsed = fetch(f"{base}/api/health", timeout)
health = json.loads(body) if body.strip().startswith("{") else {}
checks.append(
{
"name": "health",
"ok": status in {200, 503} and health.get("status") in {"ok", "degraded", "blocked"},
"status": status,
"latency_ms": round(elapsed * 1000),
"health_status": health.get("status"),
"checks": sorted((health.get("checks") or {}).keys()),
}
)
return {
"base_url": base,
"ok": all(item["ok"] for item in checks),
"checks": checks,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="https://jyotisha.chat")
parser.add_argument("--timeout", type=float, default=8.0)
args = parser.parse_args()
try:
report = check(args.base_url, args.timeout)
except Exception as error: # noqa: BLE001 - CLI smoke should report compact failure.
report = {"base_url": args.base_url, "ok": False, "error": str(error)}
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if report.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
+28
View File
@@ -0,0 +1,28 @@
from scripts.production_smoke import check
def test_health_route_declares_required_checks() -> None:
source = open("frontend/src/app/api/health/route.ts", encoding="utf-8").read()
for expected in (
"supabasePublicConfig",
"supabaseServiceRole",
"modelProvider",
"jyotishApi",
"JYOTISH_API_BASE",
):
assert expected in source
def test_production_smoke_accepts_health_degraded_status(monkeypatch) -> None:
def fake_fetch(url: str, timeout: float) -> tuple[int, str, float]:
if url.endswith("/api/health"):
return 503, '{"status":"blocked","checks":{"web":{},"jyotishApi":{}}}', 0.01
return 200, "Jyotisha", 0.01
monkeypatch.setattr("scripts.production_smoke.fetch", fake_fetch)
report = check("https://example.invalid", 1.0)
assert report["ok"] is True
assert report["checks"][1]["health_status"] == "blocked"