From f5b432a496ea4833bb60317d9d8bfdf8bb5cd389 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 2 Oct 2026 18:57:43 +0700 Subject: [PATCH] fix : correct api healthz --- README.md | 7 ++-- app/api/health/route.ts | 41 ----------------------- app/api/healthz/route.test.ts | 46 ++++++++++++++++++++++++++ app/api/healthz/route.ts | 33 +++++++++++++++++++ backend/discord.ts | 16 ++++++++- k8s.md | 2 +- k8s/base/deployment.yaml | 6 ++-- lib/health/services.test.ts | 54 +++++++++++++++++++++++++++++++ lib/health/services.ts | 31 ++++++++++++++++++ lib/redis/client.ts | 4 +++ tests/deployment-contract.test.ts | 3 +- 11 files changed, 193 insertions(+), 50 deletions(-) delete mode 100644 app/api/health/route.ts create mode 100644 app/api/healthz/route.test.ts create mode 100644 app/api/healthz/route.ts create mode 100644 lib/health/services.test.ts create mode 100644 lib/health/services.ts diff --git a/README.md b/README.md index 786e8bb..150edab 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,11 @@ formula fixtures. Their guide text and media are not imported or published. | `/admin/[character]/[page]` | Visual page editor | | `/admin/create` | Create a structured guide from the synced character catalog | | `/media/[id]` | Same-origin media response with publication checks | -| `/api/health` | Liveness response | -| `/api/health?ready=1` | PostgreSQL and Redis readiness response | +| `/api/healthz` | PostgreSQL, Redis, Slip2Go, Discord bot, and Lunaris status | + +The comprehensive check returns 503 if any dependency fails. Slip2Go uses a HEAD request to +check reachability without submitting a payment slip; it cannot validate the API secret. +The Discord check requires a heartbeat from the connected catalog worker within 45 seconds. A page is public only when both its page and character are visible. A visible character without a visible page returns 404. diff --git a/app/api/health/route.ts b/app/api/health/route.ts deleted file mode 100644 index 991c046..0000000 --- a/app/api/health/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { sql } from "drizzle-orm"; -import { connection } from "next/server"; - -import { getDb } from "@/db/client"; -import { checkReadiness } from "@/lib/health/readiness"; -import { getRedisClient } from "@/lib/redis/client"; - -const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; - -export async function GET(request: Request) { - await connection(); - const deploymentId = process.env.NEXT_DEPLOYMENT_ID || "unknown"; - const readiness = new URL(request.url).searchParams.get("ready") === "1"; - if (!readiness) { - return Response.json( - { status: "ok", deploymentId }, - { headers: NO_STORE_HEADERS }, - ); - } - - const timeout = Number(process.env.HEALTHCHECK_TIMEOUT_MS || 2_500); - const result = await checkReadiness( - { - database: async () => { - await getDb().execute(sql`select 1`); - }, - redis: async () => { - const client = await getRedisClient(); - await client.ping(); - }, - }, - Number.isFinite(timeout) ? timeout : 2_500, - ); - return Response.json( - { ...result, deploymentId }, - { - status: result.status === "ready" ? 200 : 503, - headers: NO_STORE_HEADERS, - }, - ); -} diff --git a/app/api/healthz/route.test.ts b/app/api/healthz/route.test.ts new file mode 100644 index 0000000..fd3b0ff --- /dev/null +++ b/app/api/healthz/route.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const execute = vi.fn(); +const ping = vi.fn(); +const checkSlip2Go = vi.fn(); +const checkDiscordBot = vi.fn(); +const checkLunaris = vi.fn(); + +vi.mock("next/server", () => ({ connection: vi.fn() })); +vi.mock("@/db/client", () => ({ getDb: () => ({ execute }) })); +vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ ping }) })); +vi.mock("@/lib/health/services", () => ({ checkSlip2Go, checkDiscordBot, checkLunaris })); + +const { GET } = await import("./route"); + +describe("healthz endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + execute.mockResolvedValue(undefined); + ping.mockResolvedValue("PONG"); + checkSlip2Go.mockResolvedValue(undefined); + checkDiscordBot.mockResolvedValue(undefined); + checkLunaris.mockResolvedValue(undefined); + }); + + it("checks all five dependencies", async () => { + const response = await GET(); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(await response.json()).toMatchObject({ + status: "ready", + checks: { database: "ok", redis: "ok", slip2go: "ok", discordBot: "ok", lunaris: "ok" }, + }); + }); + + it("returns 503 and identifies a failed dependency", async () => { + checkDiscordBot.mockRejectedValueOnce(new Error("offline")); + const response = await GET(); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ + status: "not-ready", + checks: { database: "ok", redis: "ok", slip2go: "ok", discordBot: "failed", lunaris: "ok" }, + }); + }); + +}); diff --git a/app/api/healthz/route.ts b/app/api/healthz/route.ts new file mode 100644 index 0000000..ae960b0 --- /dev/null +++ b/app/api/healthz/route.ts @@ -0,0 +1,33 @@ +import { sql } from "drizzle-orm"; +import { connection } from "next/server"; + +import { getDb } from "@/db/client"; +import { checkReadiness } from "@/lib/health/readiness"; +import { checkDiscordBot, checkLunaris, checkSlip2Go } from "@/lib/health/services"; +import { getRedisClient } from "@/lib/redis/client"; + +export async function GET() { + await connection(); + const deploymentId = process.env.NEXT_DEPLOYMENT_ID || "unknown"; + const configuredTimeout = Number(process.env.HEALTHCHECK_TIMEOUT_MS || 2_500); + const timeoutMs = Number.isFinite(configuredTimeout) + ? Math.max(100, Math.min(10_000, configuredTimeout)) + : 2_500; + const result = await checkReadiness({ + database: async () => { + await getDb().execute(sql`select 1`); + }, + redis: async () => { + const client = await getRedisClient(); + await client.ping(); + }, + slip2go: () => checkSlip2Go(AbortSignal.timeout(timeoutMs)), + discordBot: checkDiscordBot, + lunaris: () => checkLunaris(AbortSignal.timeout(timeoutMs)), + }, timeoutMs); + + return Response.json( + { ...result, deploymentId }, + { status: result.status === "ready" ? 200 : 503, headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/backend/discord.ts b/backend/discord.ts index fb79b75..6b9df28 100644 --- a/backend/discord.ts +++ b/backend/discord.ts @@ -10,7 +10,7 @@ import { getLatestLunarisVersion, processCatalogSync, } from "@/lib/catalog/sync"; -import { closeRedisClient } from "@/lib/redis/client"; +import { closeRedisClient, discordWorkerHeartbeatKey, getRedisClient } from "@/lib/redis/client"; const RETRY_DELAYS_MS = [0, 30_000, 120_000, 600_000, 1_800_000] as const; @@ -50,6 +50,17 @@ const client = new Client({ GatewayIntentBits.MessageContent, ], }); +let heartbeatTimer: ReturnType | undefined; + +async function writeHeartbeat(): Promise { + if (!client.isReady()) return; + try { + const redis = await getRedisClient(); + await redis.set(discordWorkerHeartbeatKey(), "1", "EX", 45); + } catch (cause) { + log("heartbeat_failed", { error: cause instanceof Error ? cause.message : String(cause) }); + } +} let pendingMessageUrl: string | null = null; let runner: Promise | null = null; @@ -168,11 +179,14 @@ try { await Promise.race([ready, stopped]); if (!shutdown.signal.aborted) { log("discord_ready", { userId: client.user?.id, channelId }); + await writeHeartbeat(); + heartbeatTimer = setInterval(() => { void writeHeartbeat(); }, 15_000); await notify("worker ready", { userId: client.user?.id, channelId }); await stopped; } } finally { shutdown.abort(); + if (heartbeatTimer) clearInterval(heartbeatTimer); client.destroy(); await Promise.resolve(runner).catch(() => undefined); await Promise.all([closeDb(), closeRedisClient()]); diff --git a/k8s.md b/k8s.md index 1bc29f2..8508468 100644 --- a/k8s.md +++ b/k8s.md @@ -82,7 +82,7 @@ kubectl rollout status deployment/buzz-sheet-discord-worker \ ## 4. Verify the deployment ```bash -curl -fsS 'https://guide.sudloh.com/api/health?ready=1' +curl -fsS 'https://guide.sudloh.com/api/healthz' bun logs ``` diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml index c799844..4f364c4 100644 --- a/k8s/base/deployment.yaml +++ b/k8s/base/deployment.yaml @@ -171,20 +171,20 @@ spec: memory: 2Gi startupProbe: httpGet: - path: /api/health + path: /api/healthz port: http failureThreshold: 30 periodSeconds: 2 readinessProbe: httpGet: - path: /api/health?ready=1 + path: /api/healthz port: http failureThreshold: 3 periodSeconds: 10 timeoutSeconds: 4 livenessProbe: httpGet: - path: /api/health + path: /api/healthz port: http failureThreshold: 3 periodSeconds: 20 diff --git a/lib/health/services.test.ts b/lib/health/services.test.ts new file mode 100644 index 0000000..2cb9a1d --- /dev/null +++ b/lib/health/services.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const exists = vi.fn(); +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: async () => ({ exists }), + discordWorkerHeartbeatKey: () => "buzz:events:discord-worker:heartbeat", +})); + +const { checkSlip2Go, checkDiscordBot, checkLunaris } = await import("./services"); +const originalFetch = globalThis.fetch; +const originalUrl = process.env.SLIP2GO_VERIFY_URL; +const originalSecret = process.env.SLIP2GO_API_SECRET; +const fetchMock = vi.fn(); + +describe("external service checks", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + process.env.SLIP2GO_VERIFY_URL = "https://slip2go.example.test/verify"; + process.env.SLIP2GO_API_SECRET = "secret"; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalUrl === undefined) delete process.env.SLIP2GO_VERIFY_URL; + else process.env.SLIP2GO_VERIFY_URL = originalUrl; + if (originalSecret === undefined) delete process.env.SLIP2GO_API_SECRET; + else process.env.SLIP2GO_API_SECRET = originalSecret; + }); + + it("checks Slip2Go reachability without submitting a slip", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 405 })); + await expect(checkSlip2Go(AbortSignal.timeout(1000))).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith( + new URL("https://slip2go.example.test/verify"), + expect.objectContaining({ method: "HEAD", cache: "no-store" }), + ); + fetchMock.mockResolvedValue(new Response(null, { status: 503 })); + await expect(checkSlip2Go(AbortSignal.timeout(1000))).rejects.toThrow("503"); + }); + + it("requires a valid Lunaris version response", async () => { + fetchMock.mockResolvedValueOnce(Response.json({ version: "7.0.54" })); + await expect(checkLunaris(AbortSignal.timeout(1000))).resolves.toBeUndefined(); + fetchMock.mockResolvedValueOnce(Response.json({ version: "invalid" })); + await expect(checkLunaris(AbortSignal.timeout(1000))).rejects.toThrow(); + }); + + it("requires a live Discord worker heartbeat", async () => { + exists.mockResolvedValueOnce(1).mockResolvedValueOnce(0); + await expect(checkDiscordBot()).resolves.toBeUndefined(); + await expect(checkDiscordBot()).rejects.toThrow("heartbeat"); + }); +}); diff --git a/lib/health/services.ts b/lib/health/services.ts new file mode 100644 index 0000000..7f747b1 --- /dev/null +++ b/lib/health/services.ts @@ -0,0 +1,31 @@ +import { lunarisVersionSchema } from "@/lib/catalog/version"; +import { discordWorkerHeartbeatKey, getRedisClient } from "@/lib/redis/client"; + +export async function checkSlip2Go(signal: AbortSignal): Promise { + const configuredUrl = process.env.SLIP2GO_VERIFY_URL; + if (!configuredUrl || !process.env.SLIP2GO_API_SECRET) throw new Error("Slip2Go is not configured."); + const url = new URL(configuredUrl); + if (url.protocol !== "https:") throw new Error("Slip2Go URL must use HTTPS."); + const response = await fetch(url, { method: "HEAD", cache: "no-store", signal }); + // The verification endpoint requires a real slip, so a rejected HEAD request can still prove reachability. + if (!response.ok && ![400, 401, 403, 405, 415, 422].includes(response.status)) { + throw new Error(`Slip2Go returned ${response.status}.`); + } +} + +export async function checkLunaris(signal: AbortSignal): Promise { + const response = await fetch("https://api.lunaris.moe/data/version.json", { cache: "no-store", signal }); + if (!response.ok) throw new Error(`Lunaris returned ${response.status}.`); + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || !("version" in body)) { + throw new Error("Invalid Lunaris version response."); + } + lunarisVersionSchema.parse(body.version); +} + +export async function checkDiscordBot(): Promise { + const redis = await getRedisClient(); + if (!(await redis.exists(discordWorkerHeartbeatKey()))) { + throw new Error("Discord worker heartbeat is missing."); + } +} diff --git a/lib/redis/client.ts b/lib/redis/client.ts index ad490c7..cc38f3a 100644 --- a/lib/redis/client.ts +++ b/lib/redis/client.ts @@ -88,3 +88,7 @@ export function redisEventPrefix(): string { export function redisEventChannel(topic: string): string { return `${redisEventPrefix()}:${topic}`; } + +export function discordWorkerHeartbeatKey(): string { + return `${redisEventPrefix()}:discord-worker:heartbeat`; +} diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts index 6c0e2da..244594f 100644 --- a/tests/deployment-contract.test.ts +++ b/tests/deployment-contract.test.ts @@ -13,8 +13,7 @@ describe("production deployment contract", () => { expect(deployment).toContain("replicas: 2"); expect(deployment).toContain("maxSurge: 1"); expect(deployment).toContain("maxUnavailable: 0"); - expect(deployment).toContain("path: /api/health?ready=1"); - expect(deployment).toContain("path: /api/health"); + expect(deployment.match(/path: \/api\/healthz\n/gu)).toHaveLength(3); expect(deployment).toContain("kubernetes.io/arch: arm64"); expect(deployment).toMatch( /requests:\s+cpu: 500m\s+memory: 1Gi\s+limits:\s+cpu: "1"\s+memory: 2Gi/u,