From 1238550d396752500600aa315fe386ff0ac5caa4 Mon Sep 17 00:00:00 2001 From: Awshesh12 Date: Thu, 13 Aug 2026 11:42:56 +0000 Subject: [PATCH] fix(router): bound fallback reachability scan by max_fallbacks A client-supplied fallback chain could recurse one level per group and exceed the stack. Cap traversal at max_fallbacks so an oversized chain fails closed, matching the runtime fallback limit --- litellm/router.py | 6 ++++-- tests/test_litellm/test_router_block_helpers.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index bc755ad4374..7059dc0fac0 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9991,9 +9991,11 @@ class Router: True when `fallbacks` routes `model_name` to a model group with at least one unblocked deployment. `fallbacks` must already reflect the precedence the router applies at call time, so a request-supplied list replaces the router-level chain - rather than merging with it. Visited groups are skipped so a cycle terminates. + rather than merging with it. Traversal stops on a repeat group and after + `max_fallbacks` hops, matching the runtime limit and bounding recursion so a long + client-supplied chain cannot exhaust the stack. """ - if model_name in visited: + if model_name in visited or len(visited) >= self.max_fallbacks: return False fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group=model_name) if not fallback_model_group: diff --git a/tests/test_litellm/test_router_block_helpers.py b/tests/test_litellm/test_router_block_helpers.py index f210371d2dd..a924fe60d06 100644 --- a/tests/test_litellm/test_router_block_helpers.py +++ b/tests/test_litellm/test_router_block_helpers.py @@ -146,6 +146,17 @@ class TestHasReachableFallback: ) assert router._has_reachable_fallback("primary", fallbacks=["fallback"]) is True + def test_chain_longer_than_max_fallbacks_fails_closed(self): + hops = 6 + router = Router( + model_list=[_deployment("m0", "m0", blocked=True)] + + [_deployment(f"m{i}", f"m{i}", blocked=True) for i in range(1, hops)] + + [_deployment("healthy", "h0", blocked=False)], + max_fallbacks=hops - 2, + ) + chain = [{f"m{i}": [f"m{i + 1}"]} for i in range(hops - 1)] + [{f"m{hops - 1}": ["healthy"]}] + assert router._has_reachable_fallback("m0", fallbacks=chain) is False + class TestIsBlockedWithoutReachableFallback: def test_blocked_and_no_fallback_returns_true(self):