Circuit breaker¶
Retries protect one call. The circuit breaker protects everything else: when an upstream is genuinely down, continuing to send it traffic makes your latency worse and its recovery slower. The breaker notices, fails fast locally for a while, then probes.
The config¶
from clientwright import CircuitBreakerConfig, ClientConfig
config = ClientConfig(
service_name="orders",
circuit_breaker=CircuitBreakerConfig(
fail_threshold=5, # consecutive qualifying failures to open
recovery_timeout=60.0, # seconds open before probing
half_open_max_calls=1, # concurrent probes allowed while half-open
),
)
On by default; circuit_breaker=None disables. State lives in the
ClientRuntime — which is why the runtime must be application-scoped:
a breaker that is recreated per request has no memory and therefore no function.
One signal per logical call¶
A subtle rule with big consequences: the breaker hears about each logical call exactly once, with its final outcome. A call that failed twice and succeeded on the third attempt is one success — retries can never pump the failure counter on their own. (The legacy pattern of counting every attempt turns a modest blip into an open circuit; clientwright counts what the caller actually experienced.)
What counts as a failure is a list of failure kinds (trip_kinds) covering
timeouts, connect/TLS/DNS errors, disconnects and protocol errors — plus 5xx
responses. A 404 or a 429 never trips the breaker: the upstream is alive and
answering, just not the way you hoped.
Cancelled calls are a third category: neither success nor failure. If the caller gave up (or an ambient deadline budget cancelled the task), the engine tells the breaker to abort the call — releasing its half-open slot without polluting the count in either direction.
The state machine¶
CLOSED --- fail_threshold failures ---> OPEN
OPEN --- recovery_timeout passed ---> HALF_OPEN (the transitioning call IS the probe)
HALF_OPEN --- probe succeeds ---> CLOSED
HALF_OPEN --- probe fails ------> OPEN (fresh recovery_timeout)
While OPEN, calls are rejected locally — no connection is attempted — with a
CircuitOpenError that includes the time until the next probe and dual-inherits
the adapter's native error family:
import httpx
from clientwright.adapters.httpx import HttpxCircuitOpenError
try:
await client.get("/stock")
except httpx.HTTPError as error: # your existing handler already catches it
...
except HttpxCircuitOpenError as error: # or catch the specific class
print(f"probe in {error.retry_after:.0f}s")
While HALF_OPEN, at most half_open_max_calls probes fly at once — including the
call that triggered the transition, which consumes a slot like any other probe.
Everyone else keeps getting fast local rejections until a probe closes the circuit.
Choosing the key¶
By default the breaker keys on origin (scheme://host:port) — one upstream,
one health verdict. Two finer granularities exist:
from clientwright import CircuitBreakerConfig, CircuitKey
CircuitBreakerConfig(key=CircuitKey.ORIGIN_ROUTE) # per (origin, route template)
CircuitBreakerConfig(key=CircuitKey.ORIGIN_METHOD) # per (origin, HTTP method)
ORIGIN_ROUTE needs the per-call route to be set — an
unset route buckets under unknown, which quietly merges endpoints back together.
The key registry is LRU-capped (max_keys, 512 by default), and an armed circuit —
open or half-open — is never evicted to make room; disarming a breaker by cache
pressure would be a silent lie.
Watching it¶
State transitions surface as the http_client_circuit_state gauge
(0 closed, 1 half-open, 2 open) labelled by key, and every locally rejected call
appears in http_client_requests_total with outcome="circuit_open" — so an open
circuit is visible on the dashboard as a spike of instant failures, not a
mysterious drop in traffic.