Health checking¶
Load balancing without health data spreads traffic evenly over live and dead
backends alike. HealthChecker (the [health] extra) probes
grpc.health.v1.Health/Check in the background and feeds both the balancer
and the channel pool.
Starting a checker¶
from grpc_client_kit import ChannelPool, HealthChecker, create_balancer
checker = HealthChecker(check_interval=30.0, timeout=5.0)
await checker.start(targets)
await checker.wait_until_ready(timeout=5.0) # await the first pass
balancer = create_balancer(targets=targets, health_checker=checker)
pool = ChannelPool(health_checker=checker) # pooled channels get health flags too
...
await checker.stop() # closes the probe channels
Each probe asks for the overall server status — the empty service name of the Health v1 protocol — rather than a per-service one, so a target is healthy when the process behind it says it is serving at all.
HealthChecker is resolved lazily, so import grpc_client_kit works on a
bare install; touching grpc_client_kit.HealthChecker without the extra
raises an ImportError naming it. HealthCheckerNotRunningError lives in
grpc_client_kit.health, which likewise needs the extra.
An unchecked target is not a healthy target¶
Health is reported from evidence only. is_healthy() returns True after a
check saw SERVING, False before that, and — when the target has no
recorded status and no loop is running to ever produce one — raises
HealthCheckerNotRunningError rather than claiming anything.
That last case is the one worth designing for. Balancers gather health with
return_exceptions=True, so the error surfaces merely as "not eligible", and
a checker nobody started looks exactly like a cluster that is entirely down.
The checker therefore also logs a one-time warning naming the real cause.
On a cold start the balancer raises NoHealthyTargetsError until the first
pass lands — which is why wait_until_ready() exists. It blocks until every
monitored target has a verdict and returns False if it timed out or the loop
is not running. Use it whenever the first RPC follows start() immediately.
Inside the probe loop¶
start() spawns one background loop for all monitored targets. Each tick it
collects the targets whose next-check time has arrived and probes those
concurrently, then sleeps a second before looking again. Calling start() on
a running checker is ignored with a warning rather than starting a second
loop.
- Scheduling is monotonic. Wall-clock jumps (NTP, DST) would otherwise stall every target's next check or stampede them all at once.
- Failures back off per target. The next check is delayed by
check_interval × 2^(failures-1), capped atmax_backoff(300s), so a dead backend is not probed at full rate while a live one keeps its interval. One success resets the counter. - A failed probe drops its channel. A half-open connection would otherwise keep failing forever; the next check reconnects. The channel is dropped before the result is published, so a callback that raises cannot leave a broken connection behind.
- Results fan out. Each result updates the cache, marks the pooled
channels of that target, and — only on a change — invokes
on_status_change. - Channels of targets that are no longer monitored are closed every five
minutes, and every probe channel is closed by
stop().
check_health(target) is the same probe as a one-shot call: it runs the RPC,
publishes the result like any scheduled check, and returns the verdict.
Probes run on their own channels, never on pooled ones¶
Three reasons, each sufficient on its own:
- a pooled channel carries the client's interceptor chain, so probing through it would pollute client metrics and count towards circuit breakers;
- pooled channels belong to application traffic and its lifecycle, while monitoring must run on its own schedule;
- a failed probe closes its channel to force a reconnect, which must never happen to a channel that application RPCs are holding.
What the factory wires for you¶
| Field | Default | Meaning |
|---|---|---|
check_interval |
30.0 |
Seconds between checks of a healthy target |
timeout |
5.0 |
Budget for one probe RPC |
max_backoff |
300.0 |
Cap on the backoff of a failing target |
The factory builds a checker only when the settings carry both a
health_checker block and targets, and passes it check_interval,
timeout, insecure and credentials. It does not forward options or
compression: if your application channels need specific channel options for
the probes to negotiate HTTP/2 identically, construct HealthChecker
yourself and hand it to the pool and the balancer.
Entering the factory's async with starts the checker; leaving it stops the
checker and closes its probe channels. A factory used without that block warns
from create_client — see Quick start.
Reacting to changes¶
async def on_change(target: str, is_healthy: bool) -> None:
logger.warning("%s is now %s", target, "up" if is_healthy else "down")
checker = HealthChecker(on_status_change=on_change)
The callback fires only on a transition, not on every probe, so it is a
suitable place for an alert or a cache invalidation. A callback that raises is
logged and swallowed, because a broken notification must not stop the
monitoring that produced it; pass fail_fast_callback=True to have it
propagate instead — useful in tests, where a silently failing callback is
worse than a loud one.