Health checks¶
One HealthRegistry per service answers two different questions:
| Probe | Question | Answer |
|---|---|---|
| Liveness | Is this process alive? | Always healthy while the event loop runs. |
| Readiness | Should traffic be routed here? | The ready flag and every registered check passing. |
Keeping them separate matters. If liveness depended on your database, a Postgres blip would make Kubernetes restart every pod instead of just routing around them.
flowchart TD
P["readiness probe"] --> F{"ready flag"}
F -->|"false — starting up<br/>or shutting down"| U["503 unhealthy"]
F -->|"true"| C{"every registered<br/>check passes?"}
C -->|"no"| U
C -->|"yes"| OK["200 ok"]
L["liveness probe"] --> A["200 — the loop is running"]
The ready flag¶
The Host owns it:
- flipped on after warmup, after
bind(), afterpre_starthooks — the last step beforepost_start; - flipped off as the very first step of shutdown, before anything stops accepting.
That is what makes a rolling deploy lossless: the load balancer removes the pod while it can still serve, and only then does the drain begin.
Registering checks¶
from servicewright import AppSpec
spec = AppSpec(service_name="orders", create_container=build_container)
spec.health.add_check("postgres", PostgresHealthCheck(session_maker))
spec.health.add_check("redis", RedisHealthCheck(redis_client))
A check is anything with one method:
So your own is trivial:
class PaymentGatewayCheck:
def __init__(self, client: PaymentClient) -> None:
self._client = client
async def check(self) -> bool:
return await self._client.ping()
Checks run concurrently on every readiness probe. A check that raises counts as a failure and
is logged with its name — it never propagates. Names must be unique; registering a duplicate
raises ValueError.
Two checks ship in the box: PostgresHealthCheck and RedisHealthCheck.
Reading the result¶
report = await spec.health.readiness()
report.healthy # bool
report.checks # {"postgres": True, "redis": False}
report.status # ProbeStatus.UNHEALTHY
Caching¶
If your checks are expensive and your probe interval is aggressive, cache the verdict:
from servicewright import AppSpec, HealthRegistry
spec = AppSpec(
service_name="orders",
create_container=build_container,
health=HealthRegistry(readiness_cache_ttl=2.0),
)
With a TTL of 0.0 (the default) every probe runs every check.
One registry, every transport¶
The same registry drives all of these — there is nothing to keep in sync:
GET /system/health/livez → 200 {"status": "ok"}
GET /system/health/readyz → 200 {"status": "ok"}
→ 503 {"status": "unhealthy"}
Paths are configurable via HttpConfig(health=HealthConfig(...)), and the routes can be
switched off entirely with HealthConfig(enabled=False).
Note the shorter paths — the Litestar binding uses its own defaults.
The standard grpc.health.v1.Health service reports SERVING exactly while readiness is
healthy.
Because that service is a push API, the entrypoint polls the registry every
health_refresh_interval seconds (5 by default) while serving, so a dependency failing
mid-life flips the health status within roughly one interval.
What a failing check does — and does not do¶
A failing check makes readiness unhealthy. That is all:
/readyzreturns 503 and the gRPC health service saysNOT_SERVING, so load balancers stop sending new work;- the process keeps running, keeps serving in-flight work, and keeps re-checking;
- nothing restarts, because liveness is unaffected.
If you want a hard failure at startup instead, use a warmer — those are fail-fast by design.
Next¶
- Warmup — the fail-fast counterpart that runs before readiness.
- Kubernetes — probe configuration that matches this behaviour.