From 5b8f631b222246be5ba54171694e80338de41a6e Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 28 Aug 2026 20:06:09 +0000 Subject: [PATCH] feat(ops): expose liveness and readiness probes --- app/api/health/route.ts | 46 +++++++++++++++++++++++++++++++++ lib/health/readiness.test.ts | 40 +++++++++++++++++++++++++++++ lib/health/readiness.ts | 50 ++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 app/api/health/route.ts create mode 100644 lib/health/readiness.test.ts create mode 100644 lib/health/readiness.ts diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..d0e9f3d --- /dev/null +++ b/app/api/health/route.ts @@ -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, + }, + ); +} diff --git a/lib/health/readiness.test.ts b/lib/health/readiness.test.ts new file mode 100644 index 0000000..c675af1 --- /dev/null +++ b/lib/health/readiness.test.ts @@ -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(); + }); +}); diff --git a/lib/health/readiness.ts b/lib/health/readiness.ts new file mode 100644 index 0000000..6501cf3 --- /dev/null +++ b/lib/health/readiness.ts @@ -0,0 +1,50 @@ +export type ReadinessStatus = "ready" | "not-ready"; + +export interface ReadinessResult { + status: ReadinessStatus; + checks: Record; +} + +async function withinTimeout( + check: () => Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + check().then(() => undefined), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("Dependency check timed out.")), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +export async function checkReadiness( + checks: Record Promise>, + timeoutMs = 2_500, +): Promise { + 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, + }; +}