Skip to content

fix(router): fail over to a sibling deployment on non-retryable 4xx errors - #46003

Open
thibault-linktree wants to merge 13 commits into
BerriAI:mainfrom
thibault-linktree:litellm_router_failover_non_retryable
Open

thibault-linktree wants to merge 13 commits into
BerriAI:mainfrom
thibault-linktree:litellm_router_failover_non_retryable

Conversation

@thibault-linktree

@thibault-linktree thibault-linktree commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Handwritten 1-3 sentence description

[insert handwritten description here]

Details

TLDR

Problem this solves:

  • A 404 or 400 from one deployment fails the whole request
  • Healthy sibling deployments in the same model group are never tried
  • Cooldown benches the broken deployment only after callers already saw errors

How it solves it:

  • Non-retryable errors now retry on a deployment that has not refused yet
  • Retries stop once every deployment has refused, so nothing repeats in place
  • Single-deployment groups, retry policies and cooldown behave as before
  • Guardrail rejections are never forwarded to another deployment

User Flow

Before: a developer calling a model group with one broken and one healthy deployment gets a 404 even though the group could have answered

  1. The proxy admin configures gpt-failover with a deployment whose model id OpenAI no longer serves, plus a healthy gpt-5.4-mini deployment
  2. The developer sends POST http://localhost:4000/v1/chat/completions with "model": "gpt-failover"
  3. The request lands on the broken deployment and comes back as a 404 saying No fallback was attempted.
  4. The next requests succeed only because the broken deployment is now cooling down, and the 404 comes back every time the cooldown expires

After: the same first request succeeds on the healthy deployment

  1. The proxy admin configures gpt-failover with a deployment whose model id OpenAI no longer serves, plus a healthy gpt-5.4-mini deployment
  2. The developer sends POST http://localhost:4000/v1/chat/completions with "model": "gpt-failover"
  3. The request lands on the broken deployment, is retried on the healthy one and comes back 200 with x-litellm-attempted-retries: 1
  4. The broken deployment still goes into cooldown, so later requests go straight to the healthy one

Relevant issues

Fixes #46002

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • Every automated review on my PR's latest commit has finished, and I have addressed or replied to each of its findings before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Both runs hit the real OpenAI API through a local proxy on port 4000 running 2 workers that share one dedicated Redis, started fresh for each run with docker run -d --rm --name litellm-qa-redis -p 127.0.0.1:6391:6379 redis:7.2.1. The native router extension was rebuilt at each SHA with uv pip install -e .. The config is a copy of litellm/proxy/dev_config.yaml with the block below added. broken-leg points at a model id OpenAI answers 404 for and carries most of the weight, so the first request lands on it

model_list:
  - model_name: gpt-failover
    litellm_params:
      model: openai/gpt-5.4-mini-retired
      api_key: os.environ/OPENAI_API_KEY
      weight: 9
    model_info:
      id: broken-leg
  - model_name: gpt-failover
    litellm_params:
      model: openai/gpt-5.4-mini
      api_key: os.environ/OPENAI_API_KEY
      weight: 1
    model_info:
      id: healthy-leg

router_settings:
  num_retries: 2
  redis_host: 127.0.0.1
  redis_port: 6391

general_settings:
  coordination_redis:
    host: 127.0.0.1
    port: 6391

Before (1432e23)

  1. Start the proxy
python litellm/proxy/proxy_cli.py --config qa_config.yaml --num_workers 2 --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log

The log shows two workers serving, Started server process [75866] and Started server process [75867]

  1. Send a request to the group
curl -s -D - localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
  -d '{"model": "gpt-failover", "messages": [{"role": "user", "content": "Say ok"}], "max_tokens": 20}'
  1. The caller gets the 404 from the broken deployment
HTTP/1.1 404 Not Found
x-litellm-model-id: broken-leg
{"error":{"message":"litellm.NotFoundError: OpenAIException - The model `gpt-5.4-mini-retired` does not exist or you do not have access to it.\n\nLiteLLM: model group 'gpt-failover' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"404"}}
  1. Redis holds deployment:broken-leg:cooldown, so only later callers are routed around it

After (5ee17bd)

  1. Start the proxy the same way. The log shows Started server process [75669] and Started server process [75670]

  2. Send the same request

  3. The caller is failed over to the healthy deployment inside the same request

HTTP/1.1 200 OK
x-litellm-model-id: healthy-leg
x-litellm-attempted-retries: 1
x-litellm-attempted-fallbacks: 0
{"id":"chatcmpl-...","model":"gpt-failover","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Ok","role":"assistant",...}}],...}
  1. Redis still holds deployment:broken-leg:cooldown, so later requests go straight to the healthy deployment

Type

🐛 Bug Fix

Changes

Upstream moved the router onto the Rust core (#50178), so the fix lives in litellm-rust/crates/router-core. should_retry in retry.rs used to refuse every non-retryable status other than 401 and 403, and every NotFoundError, so a 404 or 400 from one deployment never got a second try in its group. RetryContext now carries has_untried_deployment, and those failures are retried only while it is true

In call.rs the retry loop already pushed each refusing deployment onto skipped_ids so the next pick avoids it. It now records that id before deciding, and sets has_untried_deployment when a healthy deployment outside skipped_ids is left. Once every deployment has refused, the loop stops and returns the last error. Streams and the Python should_retry_this_error member pass false, so they behave as before

A guardrail rejection is also a 400, so the loop now returns a guardrail intervention raised by a retry as is, the same way the first attempt already did. Without that, a prompt a guardrail rejected on a retry could be forwarded to an unguarded sibling

Rust tests in retry.rs cover the 400, 404 and 422 decision with and without an untried deployment. tests/call_loop.rs covers a 400 retried on the group's other deployment, a 400 from every deployment trying each once and then taking the fallback (this replaces upstream's a_bad_request_falls_back_without_retrying, which pinned the old behavior), and a guardrail rejection on a retry not reaching the third deployment

The Python tests in tests/unit/test_router/test_router.py drive real HTTP through respx against the native core. They cover 404 and 400 failing over to a sibling, the 404 deployment still landing in cooldown, a three-deployment group failing over past two refusing deployments, a single-deployment group making exactly one call, and a request every deployment rejects making one call per deployment and raising the last error. tests/unit/test_router/core/test_retries_cooldowns.py expects a 400, context window or content policy error to try each deployment of a two-deployment group once

Mutation check on the Rust change. Never reporting an untried deployment turns 4 tests red, ignoring which deployments already refused turns 2 red, and dropping the guardrail check in the loop turns 2 red

Caveats (if any)

Medium

  • Failover only happens within the retry budget
    • With num_retries: 0 the first 4xx still reaches the caller
    • Router defaults num_retries to 2 when unset
    • A group with more broken deployments than num_retries + 1 can still fail

Low

  • A request every provider rejects now costs one call per deployment
    • Bounded by num_retries, and single-deployment groups still make one call
  • 401 and 403 keep their older multi-deployment retry rule, unchanged here

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

…rrors

should_retry_this_error raised every non-retryable status except 401/403
straight to the caller, so a 404 model_not_found or 400 from one leg of a
multi-deployment group surfaced to the client even when another deployment
in the group was healthy. The broken leg was only benched afterwards by
cooldown, so the first caller in each cooldown window still saw the failure

A non-retryable error now retries when a healthy deployment that has not
already refused this request exists. The existing retry skip list
(_retry_skipped_deployment_ids) steers the retry away from the deployments
that refused, and the retry stops as soon as none is left, so a request every
leg rejects is not repeated in place (the concern behind BerriAI#19216).
Single-deployment groups and retry policies behave as before, and the
refusing deployment still goes into cooldown
Comment thread litellm/router.py Resolved
@veria-ai

veria-ai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

Reviews

@codspeed

codspeed Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 35 untouched benchmarks


Comparing thibault-linktree:litellm_router_failover_non_retryable (6b857b9) with main (cf7c35a)

Open in CodSpeed

The router coverage gate requires every router.py function to be named in a
test, and the helper had one caller
@thibault-linktree

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Oct 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

A guardrail intervention raised by a retry attempt is a 400, so once
non-retryable errors could fail over it would be forwarded to an untried
sibling deployment instead of reaching the caller. The first attempt already
re-raised guardrail interventions; the retry loop now does too

@BrickerP BrickerP left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (COMMENTED)

Approach looks sound. The bug in #46002 is real: should_retry_this_error used to raise every non-retryable status (except 401/403) before the retry loop could use the existing _retry_skipped_deployment_ids path, so the first caller in each cooldown window ate the 4xx even when a healthy sibling existed. Computing the skip set before the check, requiring a known refusing deployment id plus an untried healthy sibling, keeping single-deployment and “every leg refused” behavior (#19216), leaving cooldown untouched, and re-raising guardrail interventions inside the retry loop all match the intended contract. The respx integration tests plus the should_retry_this_error decision table (and the guardrail regression) give good confidence.

Residuals / gaps / risks

  • Failover is bounded by num_retries, not group size. A group with more bad legs than num_retries + 1 can still miss a healthy sibling after the retry budget is exhausted. Worth a short docs/changelog note (or guidance that num_retries should be ≥ group size − 1 for this behavior).
  • num_retries=0 still surfaces the 4xx immediately even when should_retry_this_error would return True for an untried sibling — the later if num_retries > 0 / else: raise gate wins. Operators on the default-zero path will not get same-request sibling failover unless they set retries.
  • Client-error amplification (acknowledged in the PR): a genuinely malformed request that every provider rejects now costs one call per deployment (bounded by num_retries). Same path applies to 422 and other non-retryable 4xx via litellm.should_retry, not only 404 — there is no per-status opt-out (e.g. “404 only”). Fine for the reported misconfigured-model case; worth calling out for multi-leg groups.
  • _DEPLOYMENTS_ADAPTER.validate_python(...): if healthy_deployments ever has an unexpected shape, a ValidationError could mask the original upstream exception. A narrow try/except that falls back to raising error would be safer.
  • Test gaps: sibling failover for 422 (and other non-400/404 non-retryable codes) is implied by should_retry but not covered by the new respx cases; also no explicit case for “healthy sibling exists but num_retries=0 still raises.” Existing 400/422 unit tests still only assert the empty-tried_deployment_ids raise path (good for #19216), not the new positive path for those codes.
  • Observability: failover rides generic log_retry / x-litellm-attempted-retries; no distinct signal for “non-retryable sibling failover,” which may make the new behavior hard to spot in production metrics.

Tests: meaningful coverage is present (404/400 sibling success, cooldown still applied, single-deployment one-shot, N refusing then success, all-refuse stops once per leg, decision table, guardrail-on-retry). CI was still settling when I looked; worth confirming the new router unit cases stay green on the full matrix.

Not blocking merge from my side — this is a clean, well-scoped fix for a real routing hole. The residuals above are mostly documentation / edge hardening.

Comment thread litellm/router.py Resolved
Comment thread litellm/router.py Resolved
louisphamdev added a commit to louisphamdev/intact that referenced this pull request Oct 13, 2026
…all outputs, non-stream Codex (0.1.26)

- Chat -> Anthropic reads capabilities.thinking.types from the model list: a model
  that takes only adaptive thinking (Opus 4.7 and later) gets {type: adaptive} and
  output_config.effort, and no budget_tokens or sampling values, which it refuses.
  A budget model gets no thinking on a turn that continues an unsigned tool loop
  (anomalyco/opencode#54693). The list is read on demand on a cold start.
- 402, 408, 502, 504 and 529 fail over to the next account; compaction fails over
  on 401/402/403 too (BerriAI/litellm#46003).
- A clean EOF with no finish reason is an error, not response.completed, on the
  chat -> Responses and Anthropic -> chat streams; a chat pass-through read error
  reaches the next stage (router-for-me/CLIProxyAPI#6533).
- The upstream transport pings idle HTTP/2 connections (CLIProxyAPI#6534).
- A Responses input with a call and no output gets a stand-in output after the
  batch, on every route (openai/codex#44604).
- A non-streaming Responses caller on Codex gets one JSON body: intact asks for
  the stream and folds it (decolua/9router#4733).
- Bifrost no longer sends an empty Anthropic-Beta header to Codex/Antigravity.
…over_non_retryable

# Conflicts:
#	litellm/router.py
@greptile-apps

greptile-apps Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Automatic reviews are disabled for this repository.

@thibault-linktree

Copy link
Copy Markdown
Contributor Author

@litereview code-review
bugbot run

@litereview

litereview Bot commented Oct 15, 2026

Copy link
Copy Markdown

Review not started

LiteLLM team members: sign in to LiteReview with your work Google account and connect GitHub, then post your review request again.

Everyone else: comment @litereview oss-review. No sign-in needed.

…over_non_retryable

# Conflicts:
#	litellm/router.py
…over_non_retryable

Upstream taught the retry path to fail an UnsupportedEndpointError over to a
sibling. The generic non-retryable rule here already covers that case, so
should_retry_this_error keeps the generic check and reuses upstream's
_every_deployment_refused helper and _DEPLOYMENTS_ADAPTER
…over_non_retryable

# Conflicts:
#	litellm/router.py
…over_non_retryable

# Conflicts:
#	tests/unit/test_router/test_router.py
…over_non_retryable

Upstream moved the router internals from router.py into router_python_core.py,
so the non-retryable failover rule, the tried-deployment ordering and the
guardrail re-raise are ported there. The new core test that expected a 400
from both deployments of a group to stop after one call now expects each
deployment to be tried once, like the auth error case next to it
…over_non_retryable

Upstream started re-raising failed agentic follow-up calls in the retry loop
at the same spot that re-raises guardrail interventions here, so the loop now
re-raises either one, matching the first-attempt check
@CLAassistant

CLAassistant commented Nov 4, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

…over_non_retryable

Upstream deleted the Python router core (BerriAI#50178), so the failover moves to the Rust core.
should_retry retries a non-retryable failure while a healthy deployment of the group is
untried, the retry loop records the failed deployment before deciding, and a guardrail
rejection on a retry is returned as is. The should_retry_this_error and guardrail unit
tests move to router-core.
…over_non_retryable

# Conflicts:
#	litellm-rust/crates/router-core/src/call.rs

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Router does not fail over to a healthy deployment on non-retryable 4xx errors

3 participants