fix : correct api healthz
This commit is contained in:
@@ -38,8 +38,11 @@ formula fixtures. Their guide text and media are not imported or published.
|
|||||||
| `/admin/[character]/[page]` | Visual page editor |
|
| `/admin/[character]/[page]` | Visual page editor |
|
||||||
| `/admin/create` | Create a structured guide from the synced character catalog |
|
| `/admin/create` | Create a structured guide from the synced character catalog |
|
||||||
| `/media/[id]` | Same-origin media response with publication checks |
|
| `/media/[id]` | Same-origin media response with publication checks |
|
||||||
| `/api/health` | Liveness response |
|
| `/api/healthz` | PostgreSQL, Redis, Slip2Go, Discord bot, and Lunaris status |
|
||||||
| `/api/health?ready=1` | PostgreSQL and Redis readiness response |
|
|
||||||
|
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
|
A page is public only when both its page and character are visible. A visible
|
||||||
character without a visible page returns 404.
|
character without a visible page returns 404.
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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
@@ -10,7 +10,7 @@ import {
|
|||||||
getLatestLunarisVersion,
|
getLatestLunarisVersion,
|
||||||
processCatalogSync,
|
processCatalogSync,
|
||||||
} from "@/lib/catalog/sync";
|
} 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;
|
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,
|
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 pendingMessageUrl: string | null = null;
|
||||||
let runner: Promise<void> | null = null;
|
let runner: Promise<void> | null = null;
|
||||||
@@ -168,11 +179,14 @@ try {
|
|||||||
await Promise.race([ready, stopped]);
|
await Promise.race([ready, stopped]);
|
||||||
if (!shutdown.signal.aborted) {
|
if (!shutdown.signal.aborted) {
|
||||||
log("discord_ready", { userId: client.user?.id, channelId });
|
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 notify("worker ready", { userId: client.user?.id, channelId });
|
||||||
await stopped;
|
await stopped;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
shutdown.abort();
|
shutdown.abort();
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||||
client.destroy();
|
client.destroy();
|
||||||
await Promise.resolve(runner).catch(() => undefined);
|
await Promise.resolve(runner).catch(() => undefined);
|
||||||
await Promise.all([closeDb(), closeRedisClient()]);
|
await Promise.all([closeDb(), closeRedisClient()]);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ kubectl rollout status deployment/buzz-sheet-discord-worker \
|
|||||||
## 4. Verify the deployment
|
## 4. Verify the deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsS 'https://guide.sudloh.com/api/health?ready=1'
|
curl -fsS 'https://guide.sudloh.com/api/healthz'
|
||||||
bun logs
|
bun logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -171,20 +171,20 @@ spec:
|
|||||||
memory: 2Gi
|
memory: 2Gi
|
||||||
startupProbe:
|
startupProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/health
|
path: /api/healthz
|
||||||
port: http
|
port: http
|
||||||
failureThreshold: 30
|
failureThreshold: 30
|
||||||
periodSeconds: 2
|
periodSeconds: 2
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/health?ready=1
|
path: /api/healthz
|
||||||
port: http
|
port: http
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 4
|
timeoutSeconds: 4
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/health
|
path: /api/healthz
|
||||||
port: http
|
port: http
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,3 +88,7 @@ export function redisEventPrefix(): string {
|
|||||||
export function redisEventChannel(topic: string): string {
|
export function redisEventChannel(topic: string): string {
|
||||||
return `${redisEventPrefix()}:${topic}`;
|
return `${redisEventPrefix()}:${topic}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function discordWorkerHeartbeatKey(): string {
|
||||||
|
return `${redisEventPrefix()}:discord-worker:heartbeat`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ describe("production deployment contract", () => {
|
|||||||
expect(deployment).toContain("replicas: 2");
|
expect(deployment).toContain("replicas: 2");
|
||||||
expect(deployment).toContain("maxSurge: 1");
|
expect(deployment).toContain("maxSurge: 1");
|
||||||
expect(deployment).toContain("maxUnavailable: 0");
|
expect(deployment).toContain("maxUnavailable: 0");
|
||||||
expect(deployment).toContain("path: /api/health?ready=1");
|
expect(deployment.match(/path: \/api\/healthz\n/gu)).toHaveLength(3);
|
||||||
expect(deployment).toContain("path: /api/health");
|
|
||||||
expect(deployment).toContain("kubernetes.io/arch: arm64");
|
expect(deployment).toContain("kubernetes.io/arch: arm64");
|
||||||
expect(deployment).toMatch(
|
expect(deployment).toMatch(
|
||||||
/requests:\s+cpu: 500m\s+memory: 1Gi\s+limits:\s+cpu: "1"\s+memory: 2Gi/u,
|
/requests:\s+cpu: 500m\s+memory: 1Gi\s+limits:\s+cpu: "1"\s+memory: 2Gi/u,
|
||||||
|
|||||||
Reference in New Issue
Block a user