fix : correct api healthz
CI / Verify (push) Successful in 1m45s
CI / Build immutable images and deploy (push) Successful in 2m24s

This commit is contained in:
2026-10-02 18:57:43 +07:00 Unverified
parent df8c89914e
commit f5b432a496
11 changed files with 193 additions and 50 deletions
+5 -2
View File
@@ -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.
-41
View File
@@ -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,
},
);
}
+46
View File
@@ -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" },
});
});
});
+33
View File
@@ -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" } },
);
}
+15 -1
View File
@@ -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<typeof setInterval> | undefined;
async function writeHeartbeat(): Promise<void> {
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<void> | 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()]);
+1 -1
View File
@@ -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
```
+3 -3
View File
@@ -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
+54
View File
@@ -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");
});
});
+31
View File
@@ -0,0 +1,31 @@
import { lunarisVersionSchema } from "@/lib/catalog/version";
import { discordWorkerHeartbeatKey, getRedisClient } from "@/lib/redis/client";
export async function checkSlip2Go(signal: AbortSignal): Promise<void> {
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<void> {
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<void> {
const redis = await getRedisClient();
if (!(await redis.exists(discordWorkerHeartbeatKey()))) {
throw new Error("Discord worker heartbeat is missing.");
}
}
+4
View File
@@ -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`;
}
+1 -2
View File
@@ -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,