diff --git a/app/api/ready/route.ts b/app/api/ready/route.ts index 0c37117..4eeaedd 100644 --- a/app/api/ready/route.ts +++ b/app/api/ready/route.ts @@ -10,9 +10,11 @@ import { checkAllDependencies, isReady } from "@/lib/health-checks"; // readiness probe at this route, not /api/health, to stop routing traffic to // an instance whose database is down without restarting it. // -// Today the only load-bearing dependency is the database (see +// Load-bearing by absence: only the database (see // `lib/health-checks.ts#isReady`) -- engine/stripe/queue are optional -// integrations 0pt is designed to run without. +// integrations 0pt is designed to run without. Load-bearing by FAILURE: all +// of them. A dependency somebody configured and that then fails a live round +// trip makes this instance not-ready, whichever one it is. export async function GET() { const deps = await checkAllDependencies(); const ready = isReady(deps); diff --git a/lib/health-checks.ts b/lib/health-checks.ts index 2e737e0..084906d 100644 --- a/lib/health-checks.ts +++ b/lib/health-checks.ts @@ -165,10 +165,31 @@ export function hasDegradedDependency(deps: DependencyResults): boolean { /** * Readiness (unlike liveness) treats "not configured" for the deps this app - * cannot serve real traffic without as not-ready too. Only `db` is load-bearing - * today -- engine/stripe/queue are optional integrations the app is designed - * to run without (see .env.example). + * cannot serve real traffic without as not-ready too. `db` is the only + * dependency that is load-bearing merely by being absent -- engine/stripe/ + * queue are optional integrations the app is designed to run WITHOUT (see + * .env.example), so "not_configured" for those stays ready. + * + * But "optional to run without" is not the same as "optional to run BROKEN". + * A dependency in the "error" state was deliberately configured by an + * operator AND then failed a live round trip -- somebody asked for it and it + * is not answering. Readiness fails closed on that too. + * + * Measured 2026-09-25 on the production deployment (0pt.app / api.0pt.app), + * which is exactly the hole this closes: + * + * GET /api/health -> HTTP 200 {"ok":false, ..., "engine":"error", + * "detail":{"engine":"This operation was aborted"}} + * GET /api/ready -> HTTP 200 {"ready":true, "db":"ok", "engine":"error"} + * + * ENGINE_URL pointed at `http://0pt-engine:8000`, a hostname with no DNS + * record anywhere on the fleet (`getent hosts 0pt-engine` -> nothing; no such + * container on server5-11). The data engine -- 0pt's whole product -- had + * been absent for the container's entire 17.9 h uptime, and every signal + * above it was green. `ok:false` in a 200 body is not a signal; nothing reads + * bodies. */ export function isReady(deps: DependencyResults): boolean { - return deps.db.status === "ok"; + if (deps.db.status !== "ok") return false; + return !Object.values(deps).some((d) => d.status === "error"); } diff --git a/tests/lib/health-checks.test.ts b/tests/lib/health-checks.test.ts index 406b339..50ed15f 100644 --- a/tests/lib/health-checks.test.ts +++ b/tests/lib/health-checks.test.ts @@ -179,7 +179,31 @@ describe("hasDegradedDependency / isReady", () => { expect(isReady(deps({ db: { status: "error" } }))).toBe(false); }); - it("isReady is true once db is ok, regardless of optional integrations", () => { + it("isReady is true once db is ok and no configured integration is failing", () => { expect(isReady(deps({ db: { status: "ok" } }))).toBe(true); }); + + it("isReady stays true for integrations that are merely absent (0pt boots without them)", () => { + expect( + isReady( + deps({ + db: { status: "ok" }, + engine: { status: "not_configured" }, + stripe: { status: "not_configured" }, + queue: { status: "not_integrated" }, + }), + ), + ).toBe(true); + }); + + it("REGRESSION: isReady is false when a CONFIGURED integration is failing its live round trip", () => { + // Mätt i prod 2026-09-25: ENGINE_URL pekade på 0pt-engine:8000, en värd + // utan DNS-post någonstans i flottan, och /api/ready svarade ändå + // {"ready":true,...,"engine":"error"}. En konfigurerad-men-död motor är + // inte "valfri" -- någon har bett om den och den svarar inte. + expect(isReady(deps({ db: { status: "ok" }, engine: { status: "error", detail: "fetch failed" } }))).toBe( + false, + ); + expect(isReady(deps({ db: { status: "ok" }, stripe: { status: "error", detail: "bad key" } }))).toBe(false); + }); });