Repository navigation
feat(llm): graceful provider failover with ordered chain (#39) - #492
Conversation
…space-slug-uniqueness
|
@Ayinkx Implemented the graceful provider failover requested in #39, following @Triumph1701's guidance.
Verification: 30 new tests in Could you review when you get a chance? Thanks! |
1 similar comment
|
@Ayinkx Implemented the graceful provider failover requested in #39, following @Triumph1701's guidance.
Verification: 30 new tests in Could you review when you get a chance? Thanks! |
8208546 to
b55c5dc
Compare
Summary
Adds graceful LLM provider failover: an ordered, configurable provider chain in
which a provider that is still failing after its own retries are exhausted hands the
request to the next provider, while auth/validation/policy errors fail fast and a stream
that has already emitted text is never replayed.
The change is built on the existing provider layer —
LLMProvider,RetryingProvider,is_transient_error, the registry, andprovider_config— rather than beside it. Eachchain entry is a
RetryingProvider, so retries stay inside one provider; only anexhausted provider triggers failover.
Closes #39
Design
Layering
app/services/providers/failover.py(new)FailoverProvider— tries each provider in order forchat()andstream().ProviderFailoverError— raised when every provider failed; carries theper-attempt diagnostics. It is deliberately not a transient error, so wrapping
the chain in another retry layer would fail fast instead of replaying the chain.
ProviderAttempt/diagnostics()— one immutable record per attempt plus amachine-readable
{"request_id", "chain", "served_provider", "partial_provider", "failed", "attempts"}view.build_failover_chain(names, build=..., wrap=RetryingProvider)— resolves names intoproviders and wraps each in its own retry layer.
build/wrapare injected so callsites keep their existing test seams.
app/services/providers/retry.py— addsclassify_error()and stable categoryconstants (
rate_limit,unavailable,authentication,configuration,response,provider,unknown). Transient =rate_limit+unavailable, reusing the existingis_transient_errorsemantics.app/services/provider_config.py— extends the existing config withresolve_provider_chain(),routing_rules(),default_provider_chain()andprovider_chain_status().app/chat/routes.py/app/chat/api.py— build the chain at the real call sites anduse the chain-aware pre-flight status.
Configuration (backwards compatible)
LLM_PROVIDER_CHAIN— comma-separated ordered chain, e.g.openai,anthropic.LLM_ROUTING_RULES— optionalroute=provider,provider;...rules, e.g.vision=openai,anthropic;cheap=mock. A request may pass"route": "vision"; theconversation's explicit provider is always tried first, then the route/default chain.
[LLM_PROVIDER], so a single-providersetup behaves precisely as before. A single-provider chain even re-raises the original
provider error unchanged rather than wrapping it.
Mapping to the acceptance criteria
LLM_PROVIDER_CHAIN,LLM_ROUTING_RULES,resolve_provider_chain()FailoverProvider.chat/streamafterRetryingProvidergives upserved_provider+diagnostics()+chat.provider_attemptaudit eventProviderFailoverErroris aProviderError, so the existing SSEerrorevent and JSON502paths surface ittests/test_llm_failover.pyMapping to @Triumph1701's guidance
FailoverProvider.request_idis the HTTPrequest's correlation id when available, otherwise a generated id; every attempt event
for one request shares it (
test_one_event_per_attempt_with_a_shared_request_id).chat.provider_attemptrecord per providerwith
attempt,provider,status,error,error_category,transient,stream,served,partial,latency_msand thechain.classify_error()labels each attempt; onlyis_transient_errorfailures fail over,and
test_non_transient_fails_fast_without_failoverproves an auth error stops thechain without touching the next provider.
owns the answer: the error is surfaced (
partial=True,partial_providerset) and noother provider is tried, because a second provider would duplicate rendered text
(
test_partial_stream_is_not_replayed_on_failure). There are no replayable tool sideeffects in this codebase.
diagnostics()reportsserved_provider(orNone), the chain, and the per-attemptoutcome;
ProviderFailoverErrorembeds the request id and each provider's category andmessage.
Tests
Full-suite result: 16 failed, 1920 passed in 738.48s. All 16 failures are pre-existing and
unrelated: they are every test in
tests/test_chat_markdown_sanitize.py, which shells out toNode to evaluate
app/static/js/chat_markdown.jsand fails withmd.renderMarkdown is not a function— a checked-in-asset/Node-version issue in this checkout, untouched by this PR. Thetargeted suites for the provider layer, retries, chat SSE, settings, GitHub context and audit
logging all pass.
New coverage in
tests/test_llm_failover.py(30 tests):ProviderFailoverErrorwith both names, categories and therequest id, plus the SSE
errorevent at the route level;chat.provider_attemptevent per attempt sharing one logical request id;ruffandblackare not installed in the provided virtualenv (nothing was installed);python -m py_compileis clean and all touched files respect the configured 100-charline length.
Caveats / limitations
modelis passed to every provider in the chain. A fallback thatdoes not support that model raises a non-transient
ProviderResponseError, whichcorrectly fails fast rather than silently switching models.
scope here.
filtered out before the call, so a keyed fallback can serve even when the primary has
no key;
provider_chain_statusmirrors that so the pre-flight check agrees.