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
This commit is contained in:
Awshesh12 2026-08-13 11:42:56 +00:00
parent f171ef9562
commit 1238550d39
2 changed files with 15 additions and 2 deletions

View file

@ -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:

View file

@ -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):