Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044.
- live returns a fixed 200 {"status":"live"} whenever the process serves.
- ready returns 200 {"status":"ready"} only when module startup has
completed (and shutdown has not begun) and a database ping succeeds
within 2s; otherwise a fixed 503 {"code":"NOT_READY"}.
- The ping runs on the shared pool as one simple-protocol message with
a transaction-local statement_timeout, so PostgreSQL cancels a stalled
statement; the pool discards the connection on failure.
- At most one ping is ever unsettled: probes share its outcome and, once
its deadline passes, answer 503 immediately until it settles.
- Both routes set Cache-Control: no-store, accept GET only (405 with
Allow: GET otherwise, HEAD included), never resolve a principal, and
are mounted in app.ts beside, never inside, /api/v1/modules/.
- createApiApp gains a required `health` option; startApi supplies it
and clears module readiness when shutdown begins.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
import { Hono } from "hono";
|
|
|
|
/**
|
|
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
|
|
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
|
|
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
|
|
* timeouts, which bound the underlying query independently.
|
|
*/
|
|
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
|
|
|
|
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
|
|
export const HEALTH_READY_PATH = "/api/v1/health/ready";
|
|
|
|
export interface ApiHealthOptions {
|
|
/**
|
|
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
|
|
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
|
|
* reach a response. The route bounds the wait and never overlaps two calls; this function only
|
|
* has to perform the query.
|
|
*/
|
|
readonly checkDatabase: () => Promise<void>;
|
|
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
|
|
* draining instance stops reporting ready while it finishes in-flight requests. */
|
|
readonly isModuleStartupComplete: () => boolean;
|
|
}
|
|
|
|
const LIVE_BODY = Object.freeze({ status: "live" });
|
|
const READY_BODY = Object.freeze({ status: "ready" });
|
|
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
|
|
|
|
/**
|
|
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
|
|
* `timeoutMs`.
|
|
*
|
|
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
|
|
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
|
|
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
|
|
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
|
|
* so starting a fresh query on every probe would let a stalled database drain the pool one health
|
|
* check at a time.
|
|
*/
|
|
function createBoundedDatabaseCheck(
|
|
checkDatabase: () => Promise<void>,
|
|
timeoutMs: number,
|
|
): () => Promise<boolean> {
|
|
let inFlight: Promise<boolean> | undefined;
|
|
|
|
return () => {
|
|
if (inFlight !== undefined) {
|
|
return inFlight;
|
|
}
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
|
|
// any other rather than an exception escaping the route.
|
|
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
const outcome = Promise.race([
|
|
settled,
|
|
new Promise<false>((resolve) => {
|
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
}),
|
|
]);
|
|
inFlight = outcome;
|
|
void settled.then(() => {
|
|
clearTimeout(timer);
|
|
inFlight = undefined;
|
|
});
|
|
return outcome;
|
|
};
|
|
}
|
|
|
|
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
|
|
* and these routes accept GET alone. */
|
|
function methodNotAllowed(): Response {
|
|
return new Response(null, {
|
|
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
|
status: 405,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
|
|
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
|
|
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
|
|
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
|
|
* installed modules own entirely, so no module route can shadow them.
|
|
*/
|
|
export function createHealthRoutes(options: ApiHealthOptions): Hono {
|
|
const checkDatabase = createBoundedDatabaseCheck(
|
|
options.checkDatabase,
|
|
READINESS_DATABASE_TIMEOUT_MS,
|
|
);
|
|
const routes = new Hono();
|
|
|
|
routes.all(HEALTH_LIVE_PATH, (c) => {
|
|
if (c.req.method !== "GET") {
|
|
return methodNotAllowed();
|
|
}
|
|
c.header("Cache-Control", "no-store");
|
|
return c.json(LIVE_BODY, 200);
|
|
});
|
|
|
|
routes.all(HEALTH_READY_PATH, async (c) => {
|
|
if (c.req.method !== "GET") {
|
|
return methodNotAllowed();
|
|
}
|
|
c.header("Cache-Control", "no-store");
|
|
if (!options.isModuleStartupComplete()) {
|
|
return c.json(NOT_READY_BODY, 503);
|
|
}
|
|
const databaseReady = await checkDatabase();
|
|
// Checked again afterwards: shutdown may have begun while the query was in flight.
|
|
return databaseReady && options.isModuleStartupComplete()
|
|
? c.json(READY_BODY, 200)
|
|
: c.json(NOT_READY_BODY, 503);
|
|
});
|
|
|
|
return routes;
|
|
}
|