Skip to content

fix(health): readiness probe returns 503 when the database is down - #602

Merged
tompscanlan merged 2 commits into
mainfrom
fix/readiness-probe-503
Aug 24, 2026
Merged

tompscanlan merged 2 commits into
mainfrom
fix/readiness-probe-503

Conversation

@tompscanlan

@tompscanlan tompscanlan commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

readiness() wrapped the Terminus check in a try/catch and returned a plain object on failure, which NestJS serializes as HTTP 200. So a pod that can't reach Postgres passes its readiness probe:

  • a pod that loses the database stays in the Service endpoints and keeps taking traffic
  • a maxUnavailable=0 rolling update into a broken DB config replaces both pods and completes "successfully"
  • the startup probe points at the same path, so it can't catch a DB-broken start either

The old behavior was observed before the fix: with the database ping failing, GET /health/readiness answered 200 through the real Terminus pipeline (the new HTTP-level spec, run pre-fix, failed with Expected: 503, Received: 200).

Fix

  • Let the ServiceUnavailableException propagate — the 503 is how the probe reports "not ready".
  • Readiness now gates on the database only. Matrix was in the check alongside the DB ping; once the 503 propagates, a Matrix outage would have pulled every api pod out of Service. A Matrix outage degrades chat, not the API, so it comes out of readiness — /health/matrix still carries that signal. liveness is unchanged (its always-OK behavior is deliberate).

The spec test asserting the error body (should return status error when database is disconnected) encoded the old behavior and is flipped deliberately: it now asserts propagation. New HTTP-level tests cover 503 on DB-down, 200 healthy, and matrix staying out of readiness.

Consumers checked

GlobalExceptionFilter (registered via APP_FILTER in InterceptorsModule) catches the propagated exception, keeps the 503 (getStatus() for any HttpException), and rewrites the body to {statusCode, message, path, timestamp} — the HTTP spec runs through the filter so it asserts the response the probes actually see. The k8s probes, Prometheus blackbox probe (http_2xx module), and ingress health check all read the status code; nothing parses the old error body. test/kubernetes/kubernetes.e2e-spec.ts runs against a healthy environment and still passes.

One side effect to be aware of: each failing readiness hit now also increments unhandled_exceptions_total and error-logs via the filter — a DB-down pod gets loud, which seems right for this failure.

Refs: om-ep1ej

readiness() caught the Terminus ServiceUnavailableException and returned
a plain object, which NestJS serialized as HTTP 200 - so a pod that lost
Postgres stayed Ready, kept taking traffic, and a maxUnavailable=0
rollout could replace both pods into a broken DB config successfully.

Let the exception propagate so the probe sees the 503, and gate
readiness on the database only: a Matrix outage degrades chat, not the
API, and must not pull every api pod out of the Service endpoints
(/health/matrix still carries that signal).

Spec: the disconnected case now expects propagation instead of an error
body, plus HTTP-level tests through the real Terminus pipeline - the
old behavior (200 with the DB ping failing) was observed failing before
the fix.

Refs: om-ep1ej
The app registers GlobalExceptionFilter via APP_FILTER
(interceptors.module.ts), so in production it catches the propagated
ServiceUnavailableException, keeps the 503, and rewrites the body to
{statusCode, message, path, timestamp}. Mirror that in the HTTP-level
spec so the asserted response is the one the probes actually see.
@tompscanlan
tompscanlan merged commit 166b9ba into main Aug 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant