fix(readiness): /api/ready svarade 200 med en död motor i produktion #116

Merged
admin merged 1 commits from fix/readiness-fails-closed-on-broken-engine-20260925 into main 2026-09-25 12:42:43 +00:00
3 changed files with 54 additions and 7 deletions
+4 -2
View File
@@ -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);
+25 -4
View File
@@ -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");
}
+25 -1
View File
@@ -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);
});
});