Health checks and the boot contract
Formation exposes two kinds of health endpoint, and the difference between them is load-bearing. Point your platform’s checks at the right one and a bad deploy rolls back on its own; point them at the wrong one and you get crash loops, or worse, a replica that passes every probe while serving 500s.The endpoints
/health and /healthz predate /livez and answer identically; /livez is
the conventional alias so a platform’s liveness probe can name it without
ambiguity. (/healthz is kept because some managed front ends reserve it and
answer it themselves before the request reaches the container — see the note in
newMux. /health is the one guaranteed to reach Formation.)
Liveness — /livez, /health, /healthz
Always 200 ok while the process is up, with no dependency checks at all.
That is deliberate. Liveness is the is-this-process-healthy signal an
orchestrator uses to decide whether to restart, and a liveness check that pinged
the database would mark a replica unhealthy whenever the database had a blip —
on a platform that restarts on liveness failure, that turns a dependency outage,
the one failure a restart cannot fix, into a crash loop. Liveness answers
exactly one question: is this process’s HTTP loop responsive? If yes, it is
healthy.
Who acts on a failed liveness check is platform-specific. A KuberneteslivenessProberestarts the container. Fly.io, by contrast, treats a failed top-level[checks]entry as monitoring — it surfaces infly checks listand alerting but does not restart the machine on its own. On Fly, if you need restart-on-failure, wire an explicit supervisor or alert that acts on the signal; the check alone will not do it. Either way the rule is the same: liveness is dependency-free, and routing is never gated on it.
Readiness — /readyz
200 only when both of these hold, and 503 otherwise:
- Boot gate open. Boot has reached the point where this replica can answer a turn: migrations applied, agent registry loaded, listener bound, and the steady-state queue/worker loops started. The gate opens once, late in boot, and closes again the instant shutdown begins.
- Live dependency probe passes. The database answers a bounded ping (2s),
re-evaluated on every request. The ping runs on a dedicated small probe
pool (a couple of connections, separate from the application pool), so
/readyzanswers “is PostgreSQL reachable?” rather than “does the application pool have a spare connection right now?”. Binding the probe to the application pool was rejected: under load it would report a healthy database asdatabase: unreachableand shed a merely-busy replica from rotation, the opposite of what readiness is for. Pool saturation is an application-capacity concern to watch via pool-exhaustion metrics, not a database-reachability signal, and keeping the two distinct is the point of the separate probe pool.
- The boot gate without the live probe would keep advertising a replica that booted healthy and then lost its database in rotation.
- The live probe without the boot gate would advertise a replica whose database is reachable but whose schema has not been applied and whose registry has not loaded — a process that passes the ping and then 401s every real request.
503 is a diagnosis, not a mystery:
The boot contract
Boot runs in a fixed order, and each step has a defined failure posture:- Unreachable database — the pool cannot open, or its ping fails.
- Failed schema/migration step — Formation’s first write on any request is a conversation-graph insert, so a boot against a database without the current schema does not degrade, it fails every request. Serving 500s from a process whose liveness says “ok” is strictly worse than not starting, so migrations run before the listener and a failure returns instead of continuing.
- Agent registry load failure — the ingress routes against the registry, so a process that cannot read the agents table would answer 401 to every Slack event. Fatal, and paged.
- Error tracking (Sentry et al.) — brought up before the pool so the first thing that can fail already has somewhere to report, but a missing DSN or a client that will not construct is logged and boot proceeds. Formation is serving correctly; it just is not reporting.
- Telemetry/tracing — same contract. A malformed or unavailable exporter never fails a boot.
/readyz answers 503 for the entire
pre-serving window and 200 only once the replica can actually serve a turn.
On shutdown the gate closes first, before the drain, so the load balancer
routes away while in-flight turns finish rather than racing new requests into a
draining replica.
Wiring it on your platform
Route traffic on/readyz and the liveness signal on /livez. On
Fly.io (see fly.toml in the repo root):
readinessProbe points
at /readyz and a livenessProbe at /livez; a plain load balancer health
check points at /readyz. Set the readiness grace_period generously enough to
cover a cold-start migration — a slow migration must read as “not ready yet”,
never as “dead”.