feat(ops): expose liveness and readiness probes

This commit is contained in:
2026-08-28 20:06:09 +00:00 Unverified
parent 455e4240ac
commit 5b8f631b22
3 changed files with 136 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import { sql } from "drizzle-orm";
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) {
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 },
);
}
if (process.env.BUZZ_DEMO_MODE === "true") {
return Response.json(
{ status: "ready", deploymentId, checks: { demo: "ok" } },
{ 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,
},
);
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { checkReadiness } from "./readiness";
describe("readiness checks", () => {
it("reports ready only when every dependency succeeds", async () => {
await expect(
checkReadiness({ database: async () => true, redis: async () => "PONG" }),
).resolves.toEqual({
status: "ready",
checks: { database: "ok", redis: "ok" },
});
await expect(
checkReadiness({
database: async () => true,
redis: async () => {
throw new Error("offline");
},
}),
).resolves.toEqual({
status: "not-ready",
checks: { database: "ok", redis: "failed" },
});
});
it("bounds a stalled dependency", async () => {
vi.useFakeTimers();
const result = checkReadiness(
{ database: () => new Promise(() => undefined) },
100,
);
await vi.advanceTimersByTimeAsync(100);
await expect(result).resolves.toEqual({
status: "not-ready",
checks: { database: "failed" },
});
vi.useRealTimers();
});
});
+50
View File
@@ -0,0 +1,50 @@
export type ReadinessStatus = "ready" | "not-ready";
export interface ReadinessResult {
status: ReadinessStatus;
checks: Record<string, "ok" | "failed">;
}
async function withinTimeout(
check: () => Promise<unknown>,
timeoutMs: number,
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
check().then(() => undefined),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error("Dependency check timed out.")),
timeoutMs,
);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
export async function checkReadiness(
checks: Record<string, () => Promise<unknown>>,
timeoutMs = 2_500,
): Promise<ReadinessResult> {
const boundedTimeout = Math.max(100, Math.min(10_000, timeoutMs));
const entries = await Promise.all(
Object.entries(checks).map(async ([name, check]) => {
try {
await withinTimeout(check, boundedTimeout);
return [name, "ok"] as const;
} catch {
return [name, "failed"] as const;
}
}),
);
const result = Object.fromEntries(entries);
return {
status: Object.values(result).every((value) => value === "ok")
? "ready"
: "not-ready",
checks: result,
};
}