Circuit Breaker¶
Continuum Router contains a per-backend three-state circuit-breaker implementation (Closed, Open, and HalfOpen) and exposes its state through the Admin API. The ordinary LLM proxy path drives and consults it: the router checks admission after a backend is selected and before dispatch, records each request's success or failure outcome, and excludes an open backend from selection so traffic routes around it.
When every compatible backend's circuit is open, the router returns the standard service-unavailable error and participates in configured fallback. Circuit-breaker protection complements active health checks and retry/fallback routing on the inference path rather than replacing them.
State Machine¶
The implemented state machine behaves as follows:
| State | State-machine behavior |
|---|---|
closed | Records admitted successes and failures. It opens after the consecutive-failure threshold, or after the failure-rate threshold once minimum_requests is reached. |
open | Rejects calls made through CircuitBreaker::allow_request. After timeout, the next admission check moves it to half-open. |
half_open | Allows up to half_open_max_requests; enough consecutive successes close it and a failure reopens it. |
These semantics apply to the normal proxy data plane, provider-native streaming handshakes, Admin operations, and any library caller that uses the circuit-breaker API directly.
Configuration Reference¶
circuit_breaker:
enabled: true
failure_threshold: 5
failure_rate_threshold: 0.5
minimum_requests: 10
timeout: 60s
half_open_max_requests: 3
half_open_success_threshold: 2
failure_status_codes: [500, 502, 503, 504]
timeout_as_failure: true
sliding_window_size: 60s
sliding_window_type: count_based # count_based or time_based
# Optional: treat a successful but slow answer as a failure (issue #2964).
latency_as_failure:
ttft_ms: 5000 # Streaming: provider dispatch to first byte
total_ms: 60000 # Buffered: whole-response duration
minimum_samples: 10 # Judged observations before a breach counts
model_overrides:
gpt-5:
ttft_ms: 15000 # Reasoning models need a longer first-token budget
backends:
openai-primary:
failure_threshold: 10
failure_rate_threshold: 0.4
timeout: 30s
half_open_max_requests: 2
half_open_success_threshold: 2
Per-backend overrides support only the five fields shown under backends. minimum_requests, failure status codes, timeout handling, sliding-window settings, and latency_as_failure remain global; per-model latency tuning uses the block's own model_overrides map instead.
The duration fields accept units such as ms, s, m, and h. Use continuum-router config validate config.yaml before startup.
Admin Endpoints¶
When the binary includes the admin feature, the following authenticated endpoints operate on the main circuit-breaker instance:
| Method | Endpoint | Purpose |
|---|---|---|
GET | /admin/circuit/all | List every known state and a state-count summary |
GET | /admin/circuit/{backend}/status | Read one backend state; reading an unknown valid name creates/defaults a closed state |
POST | /admin/circuit/{backend}/open | Force the state open |
POST | /admin/circuit/{backend}/close | Force the state closed |
POST | /admin/circuit/{backend}/reset | Reset counters and state |
A circuit-breaker section that is absent or disabled leaves the main instance unavailable. Mutating endpoints then return 503 Service Unavailable; the list endpoint reports "enabled": false.
Example status payload:
{
"backend": "openai-primary",
"state": "closed",
"failure_count": 0,
"success_count": 0,
"last_failure_time": null,
"last_success_time": null,
"last_transition_time": null,
"next_retry_time": null,
"consecutive_successes": 0,
"half_open_requests": 0,
"statistics": {
"total_requests": 0,
"failed_requests": 0,
"successful_requests": 0,
"success_rate": "0.00%",
"times_opened": 0,
"times_closed": 0,
"average_open_duration_ms": 0.0
}
}
Changes to an existing circuit-breaker configuration are applied to the instantiated state machine through config hot reload. Enabling or disabling the optional component itself should be treated as a restart-time change.
Proxy integration¶
The data plane records an outcome exactly once per dispatch that actually reached a backend:
- A successful response records a success and, on a half-open circuit, advances it toward closing.
- A configured failure status code (
failure_status_codes, default500, 502, 503, 504) records a failure; a timeout records a failure whentimeout_as_failureis set; a connection or transport error records a failure unconditionally. - Status codes not listed in
failure_status_codes(including 4xx and 429 by default) record a neutral outcome: an admitted half-open slot is released, and the failure count, the success count, and the sliding window are all left untouched. Client/router errors that never describe an upstream response are ignored; if a path had already admitted, that only releases the slot.
Candidate filtering is read-only and happens during selection, so an open circuit is skipped in favor of a healthy peer before a backend is chosen. Half-open admission is checked only when the request is committed to upstream dispatch. That split matters for preflight and cache-aware surfaces: a selected backend may be inspected for compatibility, cache identity, guardrail input gating, or request translation without consuming a half-open probe slot.
Once dispatch begins, an open circuit (still cooling down) or a half-open circuit already at its probe ceiling rejects the request. The router treats that rejection as a retryable, fallback-aware backend-selection outcome so another eligible backend or fallback model can be tried.
Circuit-breaker coverage across proxy routes is organized as follows:
- Full admission and outcome recording:
- Chat Completions, Completions, Embeddings, Rerank, and sparse embeddings.
- Standard streaming, provider-native streaming (Anthropic, Gemini, Bedrock), and Unix-socket streaming.
- Image generation and image edit (including the Gemini image paths) and native Gemini multimodal embeddings.
- Responses ingress (
/v1/responses, all four conversion strategies plus passthrough and compact) and the Chat-Completions-to-Responses bridge. - Anthropic Messages ingress (native, OpenAI-compatible, Responses-backed, Bedrock Runtime, and web-search emulation paths) and Anthropic
count_tokenson its native Anthropic arm.
- Non-probing admission (outcome recorded for transport failures alone): the engine tokenizer routes (
/tokenize,/detokenize, and their/v1aliases) and the engine tokenize arm of Anthropiccount_tokens.- Cooldown and slot behavior: An open circuit inside its cooldown refuses these requests. Past the cooldown, admission moves the circuit to half-open as a probe would, and a half-open circuit admits them regardless of probe occupancy without taking a probe slot (
Admission::open_non_probing, backed byCircuitBreaker::allow_request_non_probingandadmits_selection_non_probingat selection). Tokenizer load therefore never delays the generation probe that can close the circuit. - HTTP status neutrality: Any HTTP response from the engine records nothing (no success, no failure, and no half-open progress). A tokenizer answer says nothing about the generation path, and a client can provoke an engine
5xxon these routes (including viacount_tokenstext). - Transport failures: Only a connection failure or a timeout is recorded as a failure (
record_non_probing_transport_failure, which counts like any failure and reopens a half-open circuit without releasing a probe slot); a timeout honorstimeout_as_failure. A circuit reached only by tokenizer traffic after opening stays half-open until a generation request settles it. count_tokensarm selection:count_tokensadmits before knowing which arm the selected backend takes, because re-selection can move it to a backend of another type. When candidates include a non-engine backend, it selects as a probe and the tokenize arm returns the slot before its call (Admission::into_non_probing); when every candidate is an engine tokenizer, it selects and admits non-probing from the start.
- Cooldown and slot behavior: An open circuit inside its cooldown refuses these requests. Past the cooldown, admission moves the circuit to half-open as a probe would, and a half-open circuit admits them regardless of probe occupancy without taking a probe slot (
- Selection-time filtering and dispatch-time admission: Both run together in every selection helper that feeds the covered paths. Admission alone would turn a servable request into a 503 whenever round-robin landed on an open circuit, while selection-time admission would let local cache hits, policy rejections, or guardrail blocks occupy scarce half-open probe slots without touching the backend.
- Not yet covered: The Responses ingress streaming service (
stream_service), its passthrough streaming variant, and the defensive unroutedstream_with_auto_backend_selectionpath. - Streaming outcome granularity: The mid-stream fallback relay records terminal completion, stream failure, or client disconnect. Every other streaming path records the handshake status only, so a backend that answers 200 and then drops the body is recorded as a success. The standard OpenAI-wire chat relay additionally applies
latency_as_failureat the first-byte seam described below; the other relays still settle at the handshake.
Latency as a failure criterion¶
A backend can be alive, answer every request with a 2xx, and still be unusable because its first token takes longer than the client is willing to wait. failure_threshold and failure_rate_threshold never see that case: the request succeeds, just too late. latency_as_failure makes a slow success count as one terminal failure, so the backend leaves rotation under the same threshold and cooldown as an erroring backend and returns through the same half-open probe.
ttft_msbounds the streaming path: the time from provider dispatch to the first upstream byte. A buffered request ignores it, because there is no first byte distinct from the whole answer.total_msbounds the buffered path: the whole-response duration. A streaming request ignores it.- At least one of the two must be set. Both may be set, and each observation uses only the threshold for its own kind; a streaming TTFT is never compared against
total_ms. minimum_samples(default10) is a warm-up measured in judged observations since the circuit last closed, reset, or had its sliding window cleared. InClosed, the firstminimum_samples - 1slow observations are recorded as successes, and only an observation at or past the floor counts as a failure. Half-open probes skip the warm-up: the circuit was opened by real evidence, so a slow probe result reopens it immediately.model_overridesmaps a served model id to its own optionalttft_ms,total_ms, andminimum_samples. An override replaces only the fields it sets.
The judged duration is partly client-influenced: a large prompt raises prefill time before the first token, and a large max_tokens raises whole-response time. With untrusted clients, raise minimum_samples and the thresholds, or scope the criterion per model with model_overrides, so an expensive request is not mistaken for a degrading backend.
Each dispatch produces exactly one outcome, never a success followed by a failure: the verdict is computed before anything is recorded, so a slow result increments the failure count without first resetting it through record_success. A fast result records an ordinary success. The transition log and the reason label on circuit_breaker_transitions_total report slow, and a slow result is counted on circuit_breaker_failures_total{error_type="slow"}.
Coverage is the standard OpenAI-wire chat path: a buffered reply is judged when the whole response has been read, and a streamed reply defers the handshake outcome to the relay's first-byte stamp. If a deferred stream ends without producing a first byte, the admission is released as neutral rather than counted as either outcome.
The latency criterion does not apply to these surfaces yet:
- The Responses ingress streaming services and their passthrough variants.
- The pre-stream and mid-stream fallback relays.
- Provider-native streaming handshakes (Anthropic, Gemini, Bedrock).
- Unix-socket streaming.
- The image, embedding, rerank, and tokenizer routes.
LeastLatency selection and this criterion are complementary. LeastLatency steers new requests toward the fastest observed backend but never removes one; latency_as_failure removes a backend whose latency has crossed an operator-set bound.
Why a non-listed status is neutral, not a success¶
A response the breaker is not configured to count is evidence that the backend answered, not evidence that it is healthy. Recording it as a success reset failure_count to zero and admitted a success into the sliding window, so a backend that interleaved 429s or 404s with genuine 5xx could never reach the threshold, and a backend answering only 429 looked permanently healthy. The neutral outcome removes both effects while still releasing the half-open probe slot the request occupied.
429 stays out of the default failure_status_codes deliberately. A transient rate limit means the backend is alive and throttling; opening its circuit would take it out of rotation for the whole cooldown and turn throttling into an outage. The router already handles 429 through the retry path, honoring the upstream Retry-After and failing fast on non-transient quota or credit exhaustion. Add 429 to failure_status_codes only when a specific backend's 429s genuinely mean it should be de-routed.
Neutral in half-open, and the cross-endpoint reset¶
HalfOpen is the one state where a neutral outcome still moves the circuit, though it does not close on neutral evidence alone. Neutral probes count as half-open progress so a backend whose transport recovered but now answers only 429 does not sit in HalfOpen indefinitely at half_open_max_requests concurrency. When half_open_success_threshold is reached, the breaker closes only if at least one probe was a genuine success; a neutral-only window reopens the circuit with a fresh cooldown. In Closed, a neutral outcome records nothing.
When a single backend serves several endpoints, failure_threshold counts consecutive failures because a success resets the count to zero. A backend whose chat endpoint is failing while its image endpoint stays healthy may not reach the absolute threshold if interleaved image successes keep resetting it. The failure_rate_threshold path still opens the circuit in that case by using the sliding window rather than the consecutive count; lower minimum_requests to engage the rate path sooner on a mixed-traffic backend.
Circuit breaker vs. health checks¶
The two mechanisms are independent and complementary. The health checker never touches the circuit breaker, and the breaker's Open to HalfOpen recovery is driven by its own cooldown plus live traffic, not by a health probe.
| Health checks | Circuit breaker | |
|---|---|---|
| Signal | Out-of-band probes on a fixed interval | In-band outcomes of real requests |
| Time to exclude a dead backend | health_checks.interval x health_checks.unhealthy_threshold (90s at the defaults of 30s and 3) | failure_threshold consecutive failures, which under load is a matter of seconds |
| Recovery | healthy_threshold successful probes | Cooldown, then a bounded half-open probe on live traffic |
The gap between those two columns is the operational point. Without a breaker, exclusion falls entirely to health checking, so for up to the full health-check window every request still tries the dead backend first. With a fallback chain configured, those requests are all rescued and nothing fails, which is exactly what makes the condition easy to miss: under sustained load the per-request connect attempt builds a backlog and first-token latency stays degraded for the whole window instead of recovering.
The router therefore emits a startup warning when fallback.fallback_chains is configured and no circuit breaker is enabled, quantifying the exposure window from the configured health_checks values:
WARN Fallback chains are configured but no circuit breaker is enabled. A backend that
dies mid-traffic stays in the per-request rotation for up to 90s
(health_checks.interval x health_checks.unhealthy_threshold) before health checking
de-routes it. ...
The breaker remains opt-in: enabling it by default would change request routing for every existing deployment. Silence the warning by adding a circuit_breaker section, not by removing the fallback chain.
Operational Guidance¶
- Configure
health_checksto remove unhealthy backends from ordinary model selection. - Configure
retryandfallbackfor transient failures and model-level alternatives. - The Admin
/admin/circuit/*endpoints report the same per-backend state the data plane uses, so they reflect circuits opened by real traffic. - Circuit-breaker Prometheus metrics (
circuit_breaker_state,circuit_breaker_failures_total,circuit_breaker_successes_total,circuit_breaker_transitions_total) are registered into the/metricsregistry and reflect real proxy traffic when themetricsfeature and a circuit breaker are both enabled.