From 7c7810df42a6f90f213b9998e9897da81b8cfb25 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:43:15 -0700 Subject: [PATCH] fix(router): ignore non-integer status codes when picking retry skips CI's router_code_coverage gate wants every function in router.py called by name from a test file with "router" in its name, and the new helper had no direct caller, so the check-quality job failed on the first tip. Covering it directly also turned up a hole. litellm._should_retry compares the status code to 500, so a provider exception carrying a string status code raises TypeError instead of answering. should_retry_this_error has the same call, but the retry policy path skips it, which is exactly the path this change enables, so the helper was the first to touch that value. Narrowing to int leaves those exceptions on the old retry-in-place behavior. --- litellm/router.py | 2 +- tests/test_litellm/test_router.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index f72f41313a0..6f1f5696847 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -7467,7 +7467,7 @@ class Router: ) -> tuple[str, ...]: failed_deployment_id: Final[str | None] = getattr(exception, "failed_deployment_id", None) status_code: Final = getattr(exception, "status_code", None) - if not failed_deployment_id or status_code is None: + if not failed_deployment_id or not isinstance(status_code, int): return () if litellm._should_retry(status_code): # pyright: ignore[reportPrivateUsage] # as in should_retry_this_error return () diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 12c1516837a..4bf75ad408c 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -13234,6 +13234,33 @@ async def test_router_retry_policy_400_retries_on_sibling_deployment( assert response._hidden_params["additional_headers"]["x-litellm-attempted-retries"] == 1 +@pytest.mark.parametrize( + "status_code,failed_deployment_id,already_skipped,healthy_deployment_ids,expected", + [ + (400, "rejecting", None, ["rejecting", "accepting"], ("rejecting",)), + (403, "rejecting", None, ["rejecting", "accepting"], ("rejecting",)), + (400, "second", ("first",), ["first", "second", "third"], ("first", "second")), + (429, "rejecting", None, ["rejecting", "accepting"], ()), + (503, "rejecting", None, ["rejecting", "accepting"], ()), + (400, "rejecting", None, ["rejecting"], ()), + (400, None, None, ["rejecting", "accepting"], ()), + (None, "rejecting", None, ["rejecting", "accepting"], ()), + ("400", "rejecting", None, ["rejecting", "accepting"], ()), + ], +) +def test_router_deployment_ids_to_skip_on_retry( + status_code, failed_deployment_id, already_skipped, healthy_deployment_ids, expected +): + exception = Exception("upstream refused this request") + exception.status_code = status_code + exception.failed_deployment_id = failed_deployment_id + healthy_deployments = [{"model_info": {"id": deployment_id}} for deployment_id in healthy_deployment_ids] + + assert ( + litellm.Router._deployment_ids_to_skip_on_retry(exception, already_skipped, healthy_deployments) == expected + ) + + def _make_failure_logging_obj(): return LiteLLMLogging( model="gpt-5.6",