ADR 005 - Resilience execution model (retries, fallback, circuit breaker, timeouts)
- Status: accepted (amended 2026-07-15: per-provider connect timeout and first-frame-peek streaming retry, see below)
- Date: 2026-07-13
Context
The resilience layer adds retries, multi-provider fallback chains, a per-provider circuit breaker and per-phase timeouts. The overriding constraint is pillar 1 (< 1 ms added p99) and pillar 3 (robustness): none of this may add a database or lock to the request path, and - the explicit lesson (LiteLLM #15526) - a storm of 429s from an upstream must never destabilise the gateway itself. The router crate was previously a pure resolution helper; this change turns it into an execution layer that wraps a provider call with the resilience machinery.
Three design tensions had to be resolved:
- Where the machinery lives, given three capability traits.
ChatProvider,EmbeddingProviderandRerankProviderhave different signatures, so a single concrete executor cannot call all three. Duplicating retry/breaker logic per capability is unacceptable. - Streaming. Retry and fallback are only safe before the first byte reaches the client (spec 6.1/6.2/6.4). Once a frame is forwarded the request is committed.
- Jitter vs. deterministic tests. Backoff needs randomness, but the acceptance criteria assert on elapsed (simulated) time.
Decision
One generic executor, capability-specific chain resolution
The executor is generic over a closure FnMut(link_index) -> Future<Result<T, ProviderError>>. It owns the resilience control flow - breaker gate, retry
loop, fallback across links, total-timeout deadline - and knows nothing about
chat/embed/rerank. Each handler resolves a chain (resolve_*_chain: the
requested model followed by its configured fallbacks, each re-resolved for the
same capability) and supplies a closure that performs the actual typed call
for a given link. The chain the executor sees is metadata only
(provider_name, model_id), which it uses to key the circuit breaker and to
report the model that actually served (x-lumen-model-used).
Fallback chains are validated at boot: every fallback id must exist and serve the same capability as the model it backs (spec 6.2). A runtime resolution miss is therefore not expected, but is treated as a skipped link rather than a panic.
Retry classification lives on ProviderError
ProviderError::is_retryable() (5xx, connect/read timeout, 429, unreachable -
never a 4xx client fault) and is_provider_fault() (does this failure indicate
the provider is unhealthy, i.e. should it count against the breaker) are the
single source of truth, shared by the retry loop and the breaker. A hard
upstream 4xx (bad request) is neither retried nor failed over - a fallback
provider would reject it too - and is returned immediately.
Backoff: pure function + injected randomness
backoff_delay(attempt, policy, retry_after, rand01) is a pure function:
exponential base·2ⁿ capped at max, equal jitter (d = e/2 + e/2·rand01, so
d ∈ [e/2, e]), then floored at Retry-After when the upstream sent a longer
one. Production passes rand01 from a cheap lock-free splitmix64 (no dependency,
no blocking, no Instant::now on the hot path); tests call the pure function
with fixed fractions and assert exact bounds. The equal-jitter floor (e/2)
makes “backoff delays respected” assertions robust regardless of the random
draw, and the Retry-After floor makes criterion 6 (Retry-After: 3 ⇒ ≥ 3 s)
hold unconditionally.
Circuit breaker: in-memory, per (provider, model)
A lock-free-ish CircuitBreaker (a Mutex<small struct> per key, never held
across an .await) transitions Closed → Open (after N consecutive
provider-fault failures) → Half-Open (after the cooldown) → one probe →
Closed/Open. Concurrent requests that find the breaker Half-Open are refused the
probe (treated as Open) so exactly one request probes. State is pushed to a
Prometheus gauge lumen_circuit_state{provider,model} (0 closed / 1 open /
2 half-open) on every transition - the telemetry crate exposes a numeric setter
so router depends on telemetry with no cycle. The breaker map is a
DashMap, entries created on first use; bounded by the (provider × model) count,
which is operator-configured and finite. Never touched off the request path by
a blocking call - health checks read a snapshot.
A logical request records one breaker outcome per link (success, or one failure after that link’s retries are exhausted), so “5 consecutive failures” means five requests, not five retries within one.
Streaming: retry/fallback only at the open phase
execute_stream retries and falls back around opening the upstream byte
stream (send + status check, before any body). Once the stream opens (upstream
returned 2xx and we commit to forwarding), the existing to_event_stream guards
(ADR 004: LM-3010 missing terminator, LM-3011 first-token, heartbeat) own the
rest and never retry. This satisfies “retry only if no chunk emitted”: an open
failure means nothing was forwarded, a post-open failure becomes a clean SSE
error frame. (A 2xx-then-immediate-error is deliberately treated as committed -
not retried - since the upstream accepted the request.)
One consequence, recorded explicitly: the circuit breaker for a streaming call
only ever sees the open phase. on_success fires as soon as the byte stream
opens, so a provider that opens cleanly but then dies mid-stream every time
(LM-3010, handled by the frame guards and never surfaced back to the breaker)
will not trip its circuit. This is the accepted trade-off of the open-phase
boundary; the frame guards still give the client a clean terminal error each
time.
Half-open cannot wedge. A half-open probe is normally resolved by
on_success/on_failure, but a probe whose result never returns (client
disconnect, the total-timeout firing mid-probe, or a non-provider-fault error
that does neither) must not pin the breaker shut. The breaker records when a
probe was admitted and auto-rearms: a probe still outstanding after a full
cooldown is presumed lost and a fresh one is admitted. The single-probe
guarantee therefore holds within a cooldown window, and recovery is always
self-healing.
Timeouts and new error codes
Three timeouts, global defaults with per-model overrides:
- connect (default 5 s) - a
reqwest::Clientsetting. Originally global only (per-provider connect would require one client per provider and lose connection pooling; deferred). Superseded by the 2026-07-15 amendment below: a provider may now override it withconnect_timeout_ms, at the cost of its own (unpooled) client. A connect timeout is distinguished from a read timeout:ProviderError::ConnectTimeout→LM-3012(504). - first_token (default 30 s, per-model override) - reuses the streaming path;
LM-3011(504). For non-streaming it bounds the whole call; for streaming, the time to the first frame. - total (default 600 s, per-model override) - an absolute deadline threaded
through the executor bounding all retries and fallbacks together; exceeding
it yields
LM-3013(504).
Circuit open with no fallback left is LM-3020 (503) carrying Retry-After
(the cooldown remainder). LM-3004 (no healthy upstream) already existed and is
kept for “all fallbacks exhausted”.
Health checks: optional, off the request path
A background task (default off) probes each provider that has a configured
base_url (self-hosted TEI/Ollama, or any explicit override) with a short GET,
storing Up/Down + latency in memory and a lumen_provider_up{provider}
gauge. Providers relying on a built-in vendor URL report unknown - the gateway
never hardcodes vendor endpoints. Results are exposed at /health/providers
for observability; the gateway’s own /health stays completely independent
of provider health (criterion 5) and does no I/O.
Consequences
- Handlers change from
route.provider.chat(...)toexecute_unary(chain, ..., |i| chain[i].provider.chat(...)); the resilience policy is uniform across all three capabilities and both streaming modes. routergains a dependency ontelemetry(gauge) and ontokio/futures(it is now async). No dependency cycle.- The circuit-breaker map and health results are process-wide in-memory state in
AppState; nothing resilience-related touches SQLite on the request path. usage_loggains amodel_usedcolumn (migration 0002) so a fallback is observable after the fact, mirroring thex-lumen-model-usedheader.- Per-provider connect timeouts and true first-frame-peek streaming retries are
explicitly out of scope here and recorded in
docs/backlog.md. (Per-provider connect timeouts were subsequently implemented; see the amendment below.)
Amendment (2026-07-15): per-provider connect timeout
The original decision left connect global-only because a per-provider connect
timeout is a reqwest::Client setting and one pooled client is shared across
all providers, so overriding it per provider would mean one client per provider
and the loss of cross-provider connection pooling. Issue #24 asked for it anyway
(an upstream that is reliably reachable can afford a much tighter connect
deadline than a flaky one; a distant self-hosted box may need a looser one), so
the deferral is superseded.
Chosen design. The shared, pooled client remains the default and carries the
global resilience.connect_timeout_ms. A provider that sets the new optional
connect_timeout_ms (alongside the existing per-provider first_token_timeout_ms
and total_timeout_ms) is given its own reqwest::Client, built once at
registry construction, with that connect timeout and the same overall backstop
as the shared client. Every provider that does not override keeps sharing the
one pooled client, so pooling is preserved for the common case and only an
explicitly-overriding provider pays for it.
Trade-off (documented, accepted). An overriding provider no longer shares the process-wide connection pool: its connections are pooled only within its own dedicated client. This is a per-provider, opt-in cost. Nothing else about the provider changes (same overall cap, same executor timeouts, same error codes).
Where it lives. ProviderSpec gains connect_timeout_ms: Option<u64>;
Registry::build/reload take the overall backstop and build the dedicated
clients in build_inner. Because the registry rebuilds all clients from the new
specs on every hot reload, changing (adding, editing or removing) a provider’s
connect_timeout_ms takes effect on SIGHUP/file-change reload with no restart,
exactly like the other two per-provider timeout overrides. Config validation
rejects a connect_timeout_ms of 0, matching the other overrides.
Amendment - 2026-07-15: first-frame-peek streaming retry (issue #7)
Supersedes the “2xx-then-immediate-error is deliberately treated as committed”
carve-out above (the parenthetical in Streaming: retry/fallback only at the
open phase) and the matching docs/backlog.md deferral. The rest of the
original decision stands unchanged.
Decision
The commitment point for a streaming response moves from the open (2xx + headers) to the first content frame. After the open succeeds, the streaming closure PEEKS the first upstream frame before committing:
- first item is a content frame (
Ok) - commit: the peeked frame is re-attached ahead of the untouched remainder (a singleBytes, moved not copied) and the reconstructed stream is handed to the existingto_event_streamguards. From here nothing retries (mid-stream errors still become a terminal SSE error frame - LM-3010/LM-3003 - exactly as before); - first item is an error (
Err), or the stream ends before any frame (None) - a pre-commit failure: the closure returnsErr, so the executor retries, falls over to the next link, and charges the circuit breaker identically to an open failure. ANoneis surfaced as the newProviderError::EmptyStream(retryable, provider-fault, mapped to LM-3010).
Boundaries and invariants preserved
- Zero-copy / bounded buffering (pillar 1, ADR 004). The peek buffers at most one frame, never the stream. The committed body is byte-identical to the upstream.
- Cancellation. The peek races the request
CancellationToken; a client disconnect during the peek window returnsProviderError::Cancelledand drops the stream, aborting the upstream.Cancelledis neither retryable nor a provider fault, so no fallback is attempted and the breaker is untouched. - Time bound. The peek runs inside the closure the executor already wraps in
the per-attempt
first_tokentimeout, so a silent upstream (headers, then no bytes) tripsFirstTokenTimeoutand fails over rather than hanging. - Still non-retryable (unchanged): everything post-commit. Once a content
frame is forwarded, a later mid-stream error, a missing
[DONE](LM-3010) or a first-token gap on a subsequent frame is a terminal SSE error, never a retry.
Consequence for the breaker note above
The recorded trade-off (“the breaker only ever sees the open phase”) is now narrower: a provider that opens 200 then fails on its first frame does now count against its breaker. Only failures after the first committed content frame remain invisible to the breaker (the frame guards still give the client a clean terminal error each time).
The peek lives in crates/router/src/peek.rs (generic over the frame type,
unit-tested for commit / error / empty / cancel / timeout); wiremock acceptance
tests are in crates/server/tests/resilience.rs.