feat(api): add unauthenticated health endpoints
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d593c10f1d
commit
aaec9789c4
@@ -31,6 +31,7 @@ import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
@@ -137,6 +138,13 @@ export interface CreateApiAppOptions {
|
||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||
* ordering, module startup, or any authorization decision. */
|
||||
readonly dashboard: DashboardStore;
|
||||
/**
|
||||
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
|
||||
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
|
||||
*/
|
||||
readonly health: ApiHealthOptions;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||
@@ -341,6 +349,11 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}
|
||||
});
|
||||
|
||||
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
|
||||
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
|
||||
// own entirely. See `./health-routes.js`.
|
||||
app.route("/", createHealthRoutes(options.health));
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
|
||||
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
|
||||
readonly module: ModuleDatabase;
|
||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/**
|
||||
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||
* caller may surface it.
|
||||
*/
|
||||
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
function pingStatement(statementTimeoutMs: number): string {
|
||||
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||
}
|
||||
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||
@@ -58,5 +78,8 @@ export function createApiDatabase(
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleConfiguration: createModuleConfigurationView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
ping: async (statementTimeoutMs: number) => {
|
||||
await pool.query(pingStatement(statementTimeoutMs));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
import { withDevicePrincipals } from "./device-principal.js";
|
||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
|
||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||
|
||||
const connectionTimeoutMs = 10_000;
|
||||
@@ -576,6 +577,10 @@ export async function startApi(
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||
// `503` while the instance still finishes in-flight requests.
|
||||
let shuttingDown = false;
|
||||
|
||||
let app: Hono;
|
||||
try {
|
||||
app = createApiApp({
|
||||
@@ -620,6 +625,12 @@ export async function startApi(
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
health: {
|
||||
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
|
||||
isModuleStartupComplete: () => !shuttingDown,
|
||||
},
|
||||
devices: {
|
||||
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
||||
// base URL rather than from any request. Production is https by configuration policy, so a
|
||||
@@ -677,6 +688,7 @@ export async function startApi(
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
shuttingDown = true;
|
||||
shutdownPromise = (async () => {
|
||||
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||
// completes: an in-flight module command may still commit and publish its resulting state.
|
||||
|
||||
Reference in New Issue
Block a user