litellm/tests/test_litellm/test_router_weighted_failover.py
ryan-crabbe-berri 680bcfd8aa
test(lint): ban blind pytest.raises(Exception) with ruff B017 (#37731)
* test(lint): ban blind pytest.raises(Exception) with ruff B017

A bare pytest.raises(Exception) accepts whatever the body throws. The TypeError
a refactor introduces satisfies it exactly as well as the rejection the test was
written for, so the crash reads as a pass and the test never goes red.

All 111 existing sites are narrowed here. A runtime probe recorded the concrete
exception each one actually catches, and each site now names that type. Where
the code under test genuinely raises a bare Exception, the site pins a stable
slice of the message with match= instead.

Two sites tell on themselves. The shared responses-API cancel test raises
"custom_llm_provider is required but passed as None" rather than talking to a
provider at all, because cancel_responses takes a provider, not a model. And
test_bedrock_guardrails_with_streaming was the only test in its file still
passing without AWS credentials, because the NoCredentialsError boto3 raised
long before the guardrail ran satisfied the blind raises.

* fix(test): widen the openai batch-dispatch assertion to OpenAIError

The narrowed NotFoundError only holds where OPENAI_API_KEY is set. Without one
the SDK raises OpenAIError while building the client, long before any 404, so CI
went red. OpenAIError covers both and still rejects a TypeError from a refactor.
2026-08-20 18:09:42 -07:00

946 lines
33 KiB
Python

"""
Tests for weighted-routing failover (router_settings.enable_weighted_failover).
When enabled and the routing strategy is "simple-shuffle", a retryable failure
on one deployment causes the request to re-pick a different deployment in the
SAME model group (weighted across the remaining deployments) before any
cross-group fallback runs.
"""
from collections import Counter
from typing import Optional
from unittest.mock import AsyncMock, patch
import pytest
import litellm
from litellm import Router
from litellm.utils import _get_excluded_filtered_deployments
# ---------------------------------------------------------------------------
# Unit tests for _get_excluded_filtered_deployments
# ---------------------------------------------------------------------------
def _make_dep(dep_id: str, weight: Optional[int] = None) -> dict:
params: dict = {"model": "gpt-4o", "api_key": "key"}
if weight is not None:
params["weight"] = weight
return {
"model_name": "test-model",
"litellm_params": params,
"model_info": {"id": dep_id},
}
class TestGetExcludedFilteredDeployments:
def test_no_excluded_returns_all(self):
deps = [_make_dep("a"), _make_dep("b")]
result = _get_excluded_filtered_deployments(deps, excluded_deployment_ids=None)
assert len(result) == 2
def test_empty_excluded_returns_all(self):
deps = [_make_dep("a"), _make_dep("b")]
result = _get_excluded_filtered_deployments(deps, excluded_deployment_ids=[])
assert len(result) == 2
def test_drops_excluded(self):
deps = [_make_dep("a"), _make_dep("b"), _make_dep("c")]
result = _get_excluded_filtered_deployments(deps, excluded_deployment_ids=["b"])
ids = sorted(d["model_info"]["id"] for d in result)
assert ids == ["a", "c"]
def test_all_excluded_returns_empty(self):
# When every healthy deployment has been excluded, the helper must
# return an empty list so the caller raises its usual no-deployments
# error. Returning the original list here would re-include the
# just-failed deployment and let weighted failover re-pick it.
deps = [_make_dep("a"), _make_dep("b")]
result = _get_excluded_filtered_deployments(deps, excluded_deployment_ids=["a", "b"])
assert result == []
def test_excluded_set_with_unknown_ids(self):
deps = [_make_dep("a"), _make_dep("b")]
result = _get_excluded_filtered_deployments(deps, excluded_deployment_ids=["zzz"])
assert len(result) == 2
def test_handles_missing_model_info(self):
deps = [
{"model_name": "x", "litellm_params": {"model": "gpt-4o"}}, # no model_info
_make_dep("b"),
]
result = _get_excluded_filtered_deployments(deps, excluded_deployment_ids=["b"])
assert len(result) == 1
# ---------------------------------------------------------------------------
# Router helpers (router_code_coverage.py requires these names in a *router* test file)
# ---------------------------------------------------------------------------
def test_set_failed_deployment_id_on_exception():
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "key"},
"model_info": {"id": "dep-a"},
}
],
)
exc = Exception("fail")
dep = _make_dep("dep-a")
router._set_failed_deployment_id_on_exception(exc, dep)
assert getattr(exc, "failed_deployment_id", None) == "dep-a"
router._set_failed_deployment_id_on_exception(exc, _make_dep("dep-b"))
assert exc.failed_deployment_id == "dep-a"
def test_stamp_failed_deployment_id_with_effective_model_info_prefers_kwargs():
"""kwargs["model_info"] (the dynamic client-side-credential id, when present) must win
over the static deployment's model_info, so a bad-credential tenant's failures are
attributed to their own dynamic deployment id, not the shared static one."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "key"},
"model_info": {"id": "dep-a"},
}
],
)
exc = Exception("fail")
router._stamp_failed_deployment_id_with_effective_model_info(
exc, _make_dep("dep-a"), {"model_info": {"id": "dynamic-dep"}}
)
assert exc.failed_deployment_id == "dynamic-dep"
def test_stamp_failed_deployment_id_with_effective_model_info_falls_back_to_deployment():
"""With no dynamic id in kwargs (the common, non-client-side-credential case), the
static deployment's own model_info.id must still be stamped."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "key"},
"model_info": {"id": "dep-a"},
}
],
)
exc = Exception("fail")
router._stamp_failed_deployment_id_with_effective_model_info(exc, _make_dep("dep-a"), {})
assert exc.failed_deployment_id == "dep-a"
@pytest.mark.asyncio
async def test_ageneric_api_call_with_fallbacks_helper_stamps_failed_deployment_id():
"""_ageneric_api_call_with_fallbacks_helper must stamp failed_deployment_id on a
failure, same as _completion/_acompletion, so callers identifying the failed
deployment (cooldown, weighted failover) work for this call type too instead of
depending on which metadata bucket ("metadata" vs "litellm_metadata") it uses."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "test-key"},
"model_info": {"id": "dep-a"},
}
],
)
async def _failing_original_function(**kwargs):
raise RuntimeError("boom")
with pytest.raises(RuntimeError) as exc_info:
await router._ageneric_api_call_with_fallbacks_helper(
model="test-model",
original_generic_function=_failing_original_function,
)
assert getattr(exc_info.value, "failed_deployment_id", None) == "dep-a"
@pytest.mark.asyncio
async def test_ageneric_api_call_with_fallbacks_helper_stamps_dynamic_id_for_clientside_credentials():
"""A client-side-credential call (tenant-supplied api_key) generates a dynamic
deployment id distinct from the shared static deployment. Stamping the static id
instead would let one tenant's bad credentials cool down the deployment every
other tenant sharing this config relies on."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "test-key"},
"model_info": {"id": "dep-a"},
}
],
)
async def _failing_original_function(**kwargs):
raise RuntimeError("boom")
with pytest.raises(RuntimeError) as exc_info:
await router._ageneric_api_call_with_fallbacks_helper(
model="test-model",
original_generic_function=_failing_original_function,
api_key="tenant-supplied-key",
litellm_metadata={"model_group": "test-model"},
)
failed_deployment_id = getattr(exc_info.value, "failed_deployment_id", None)
assert failed_deployment_id is not None
assert failed_deployment_id != "dep-a"
@pytest.mark.asyncio
async def test_acompletion_stamps_dynamic_id_for_clientside_credentials():
"""Same bug as the generic-API-call helper above, but in the regular completion
path: _acompletion's exception handlers must stamp the dynamic client-side-credential
deployment id, not the shared static deployment's id."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "test-key"},
"model_info": {"id": "dep-a"},
}
],
)
with patch("litellm.acompletion", new_callable=AsyncMock, side_effect=RuntimeError("boom")):
with pytest.raises(RuntimeError) as exc_info:
await router._acompletion(
model="test-model",
messages=[{"role": "user", "content": "Hello"}],
api_key="tenant-supplied-key",
metadata={"model_group": "test-model"},
)
failed_deployment_id = getattr(exc_info.value, "failed_deployment_id", None)
assert failed_deployment_id is not None
assert failed_deployment_id != "dep-a"
@pytest.mark.asyncio
async def test_acompletion_stamps_dynamic_id_for_clientside_credentials_on_timeout():
"""Same bug as the RuntimeError case above, but for the separate `except litellm.Timeout`
branch in `_acompletion`: it has its own call to the stamping helper, so a fix that only
covers the generic `except Exception` branch would leave a caller-supplied timeout
(`litellm.Timeout` is what `x-litellm-timeout` maps to) stamping the shared static id."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "test-key"},
"model_info": {"id": "dep-a"},
}
],
)
timeout_exc = litellm.Timeout(message="boom", model="test-model", llm_provider="openai")
with patch("litellm.acompletion", new_callable=AsyncMock, side_effect=timeout_exc):
with pytest.raises(litellm.Timeout) as exc_info:
await router._acompletion(
model="test-model",
messages=[{"role": "user", "content": "Hello"}],
api_key="tenant-supplied-key",
metadata={"model_group": "test-model"},
)
failed_deployment_id = getattr(exc_info.value, "failed_deployment_id", None)
assert failed_deployment_id is not None
assert failed_deployment_id != "dep-a"
def test_completion_stamps_dynamic_id_for_clientside_credentials():
"""Sync counterpart: _completion's exception handler must stamp the dynamic
client-side-credential deployment id, not the shared static deployment's id."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "test-key"},
"model_info": {"id": "dep-a"},
}
],
)
with patch("litellm.completion", side_effect=RuntimeError("boom")):
with pytest.raises(RuntimeError) as exc_info:
router._completion(
model="test-model",
messages=[{"role": "user", "content": "Hello"}],
api_key="tenant-supplied-key",
metadata={"model_group": "test-model"},
)
failed_deployment_id = getattr(exc_info.value, "failed_deployment_id", None)
assert failed_deployment_id is not None
assert failed_deployment_id != "dep-a"
@pytest.mark.asyncio
async def test_maybe_run_weighted_failover_returns_none_without_failed_id():
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "key", "weight": 1},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "key", "weight": 1},
"model_info": {"id": "B"},
},
],
routing_strategy="simple-shuffle",
enable_weighted_failover=True,
)
result = await router._maybe_run_weighted_failover(
exception=Exception("fail"),
original_model_group="test-model",
all_deployments=[_make_dep("A"), _make_dep("B")],
args=(),
kwargs={"metadata": {}},
input_kwargs={},
)
assert result is None
@pytest.mark.asyncio
async def test_maybe_run_weighted_failover_persists_excluded_ids_to_kwargs(monkeypatch):
"""Regression: writing to the metadata dict returned by `setdefault` must
update the dict in `kwargs` itself so the next hop sees prior exclusions.
Previously `setdefault(..., {}) or {}` returned a disconnected dict on the
first hop, dropping `_failover_excluded_ids` writes.
"""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "B"},
},
],
routing_strategy="simple-shuffle",
enable_weighted_failover=True,
)
async def _stub_run_async_fallback(*args, **kwargs):
return "ok"
monkeypatch.setattr("litellm.router.run_async_fallback", _stub_run_async_fallback)
exc = Exception("fail")
exc.failed_deployment_id = "A"
kwargs: dict = {"metadata": {}}
await router._maybe_run_weighted_failover(
exception=exc,
original_model_group="test-model",
all_deployments=[_make_dep("A"), _make_dep("B")],
args=(),
kwargs=kwargs,
input_kwargs={},
)
# The dict inside kwargs must reflect the write — proves `meta` was the
# same object as kwargs["metadata"] (no disconnected copy).
assert kwargs["metadata"].get("_failover_excluded_ids") == ["A"]
# ---------------------------------------------------------------------------
# Integration tests for weighted-failover end-to-end via Router
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_no_failover_when_flag_off():
"""Default behavior: a failure on the picked deployment surfaces to caller."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("region-A failed"),
"weight": 1,
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "ok from B",
"weight": 0, # weight=0 so A is always picked
},
"model_info": {"id": "B"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
# enable_weighted_failover defaults to False
)
with pytest.raises(litellm.InternalServerError):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
@pytest.mark.asyncio
async def test_failover_lands_on_other_deployment_when_flag_on():
"""Flag on: when A fails, request must succeed via B in the same call."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("region-A down"),
"weight": 1, # always picked first (B has weight 0)
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "ok from B",
"weight": 0,
},
"model_info": {"id": "B"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
enable_weighted_failover=True,
)
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "B"
@pytest.mark.asyncio
async def test_failover_chain_three_deployments():
"""A and B fail, request succeeds on C."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("A down"),
"weight": 1_000_000, # A always picked first
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("B down"),
"weight": 1, # picked when A is excluded
},
"model_info": {"id": "B"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "ok from C",
"weight": 0,
},
"model_info": {"id": "C"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
enable_weighted_failover=True,
)
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "C"
@pytest.mark.asyncio
async def test_failover_exhausted_raises_original_error_class():
"""When ALL deployments fail, the request raises (does not hang)."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("A down"),
"weight": 1,
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("B down"),
"weight": 1,
},
"model_info": {"id": "B"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
enable_weighted_failover=True,
)
with pytest.raises(litellm.InternalServerError):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
@pytest.mark.asyncio
async def test_failover_falls_through_to_external_fallback():
"""When all deployments in the group fail, external fallback still runs."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("A down"),
"weight": 1,
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("B down"),
"weight": 1,
},
"model_info": {"id": "B"},
},
{
"model_name": "fallback-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "ok from fallback",
},
"model_info": {"id": "fallback"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
enable_weighted_failover=True,
fallbacks=[{"test-model": ["fallback-model"]}],
)
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "fallback"
@pytest.mark.asyncio
async def test_weights_respected_when_all_healthy():
"""With both regions healthy, the picker should still honor configured
weights — failover must not change the steady-state load shape."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "from A",
"weight": 80,
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "from B",
"weight": 20,
},
"model_info": {"id": "B"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
enable_weighted_failover=True,
)
counts: Counter = Counter()
for _ in range(1000):
resp = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
counts[resp._hidden_params["model_id"]] += 1
# Expect ~80/20 split. Loose bounds to keep the test stable under CI load.
assert counts["A"] > counts["B"] * 2 # A should heavily dominate
assert counts["B"] > 50 # but B should still get a meaningful share
@pytest.mark.asyncio
async def test_failover_skipped_for_non_simple_shuffle():
"""Weighted failover is only wired up for `simple-shuffle`. With another
strategy, a failure on the picked deployment must NOT silently retry the
other deployment in the same group. Both deployments fail here to keep the
test deterministic regardless of which one the strategy picks first.
"""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("A down"),
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("B down"),
},
"model_info": {"id": "B"},
},
],
routing_strategy="latency-based-routing",
num_retries=0,
enable_weighted_failover=True,
)
with pytest.raises(litellm.InternalServerError):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
@pytest.mark.asyncio
async def test_failover_skipped_for_context_window_error():
"""ContextWindowExceededError must NOT trigger weighted failover —
it has its own dedicated fallback path. Uses the router's built-in
`mock_testing_context_fallbacks` to deterministically raise the right
exception class.
"""
import litellm
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "ok from A",
"weight": 1,
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "ok from B",
"weight": 1,
},
"model_info": {"id": "B"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
enable_weighted_failover=True,
)
with pytest.raises(litellm.ContextWindowExceededError):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
mock_testing_context_fallbacks=True,
)
@pytest.mark.asyncio
async def test_user_config_two_region_failover():
"""Mirrors the user's actual proxy_server_config.yaml shape: two Azure
regions weighted 50/50, num_retries=0. With the flag on, a failure in
one region is recovered by the other in the same request."""
router = Router(
model_list=[
{
"model_name": "gpt-5.4-mini",
"litellm_params": {
"model": "azure/deployment-eastus2",
"api_key": "bad",
"api_base": "https://eastus2.example",
"mock_response": Exception("eastus2 5xx"),
"weight": 50,
},
"model_info": {"id": "eastus2"},
},
{
"model_name": "gpt-5.4-mini",
"litellm_params": {
"model": "azure/deployment-northcentralus",
"api_key": "good",
"api_base": "https://northcentralus.example",
"mock_response": "ok from northcentralus",
"weight": 50,
},
"model_info": {"id": "northcentralus"},
},
],
routing_strategy="simple-shuffle",
cooldown_time=120,
num_retries=0,
enable_pre_call_checks=True,
disable_cooldowns=False,
allowed_fails=5,
enable_weighted_failover=True,
)
# Force eastus2 to be picked first by leaving its weight intact and
# asserting we always end up on northcentralus when eastus2 errors.
# Run several requests and ensure we never see an unhandled failure.
successes = Counter()
for _ in range(20):
resp = await router.acompletion(
model="gpt-5.4-mini",
messages=[{"role": "user", "content": "hi"}],
)
successes[resp._hidden_params["model_id"]] += 1
# With one region permanently failing, every request must land on the
# other region (either directly because it was picked first, or via
# failover because eastus2 was picked first).
assert successes["northcentralus"] == 20
assert successes["eastus2"] == 0
# ---------------------------------------------------------------------------
# Tests for healthy-deployment-only check in _maybe_run_weighted_failover
# (Issue: weighted failover checked all deployments, not just healthy ones)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_maybe_run_weighted_failover_skips_when_remaining_all_in_cooldown(
monkeypatch,
):
"""When every non-excluded deployment is in cooldown, _maybe_run_weighted_failover
must return None immediately without invoking run_async_fallback.
Previously the check was against all_deployments (including cooldown ones), so
run_async_fallback would be called unnecessarily and would raise RouterRateLimitError.
"""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "B"},
},
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "C"},
},
],
routing_strategy="simple-shuffle",
enable_weighted_failover=True,
)
# A just failed; B and C are both in cooldown.
exc = Exception("A down")
exc.failed_deployment_id = "A"
run_async_fallback_called = False
async def _should_not_be_called(*args, **kwargs):
nonlocal run_async_fallback_called
run_async_fallback_called = True
return "should not reach here"
monkeypatch.setattr("litellm.router.run_async_fallback", _should_not_be_called)
# Patch cooldown so B and C appear in cooldown.
with patch(
"litellm.router._async_get_cooldown_deployments",
new=AsyncMock(return_value=["B", "C"]),
):
result = await router._maybe_run_weighted_failover(
exception=exc,
original_model_group="test-model",
all_deployments=[_make_dep("A"), _make_dep("B"), _make_dep("C")],
args=(),
kwargs={"metadata": {}},
input_kwargs={},
)
assert result is None, "Should return None when all remaining deployments are in cooldown"
assert not run_async_fallback_called, "run_async_fallback must NOT be called when no healthy deployments remain"
@pytest.mark.asyncio
async def test_maybe_run_weighted_failover_proceeds_when_one_healthy_remains(
monkeypatch,
):
"""When at least one non-excluded deployment is healthy (not in cooldown),
_maybe_run_weighted_failover should still invoke run_async_fallback normally.
"""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "B"},
},
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "k", "weight": 1},
"model_info": {"id": "C"},
},
],
routing_strategy="simple-shuffle",
enable_weighted_failover=True,
)
# A just failed; B is in cooldown; C is healthy.
exc = Exception("A down")
exc.failed_deployment_id = "A"
run_async_fallback_called = False
async def _stub_run_async_fallback(*args, **kwargs):
nonlocal run_async_fallback_called
run_async_fallback_called = True
return "ok from C"
monkeypatch.setattr("litellm.router.run_async_fallback", _stub_run_async_fallback)
with patch(
"litellm.router._async_get_cooldown_deployments",
new=AsyncMock(return_value=["B"]),
):
result = await router._maybe_run_weighted_failover(
exception=exc,
original_model_group="test-model",
all_deployments=[_make_dep("A"), _make_dep("B"), _make_dep("C")],
args=(),
kwargs={"metadata": {}},
input_kwargs={},
)
assert result == "ok from C"
assert run_async_fallback_called, "run_async_fallback must be called when a healthy deployment remains"
@pytest.mark.asyncio
async def test_failover_falls_through_to_external_fallback_when_remaining_in_cooldown():
"""End-to-end: when the only non-failed deployments are in cooldown,
weighted failover must fall through to the configured cross-group fallback.
Without the fix the _maybe_run_weighted_failover would invoke run_async_fallback
unnecessarily (because it counted cooldown deployments as "remaining"), get back
RouterRateLimitError, return None, and reach the same fallback path — but only
incidentally. With the fix the early-exit path is taken directly.
"""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("A down"),
"weight": 1_000_000, # always picked first
},
"model_info": {"id": "A"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("B down"),
"weight": 1,
},
"model_info": {"id": "B"},
},
{
"model_name": "fallback-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "ok from fallback",
},
"model_info": {"id": "fallback"},
},
],
routing_strategy="simple-shuffle",
num_retries=0,
enable_weighted_failover=True,
fallbacks=[{"test-model": ["fallback-model"]}],
)
# Put B in cooldown so weighted failover can't use it after A fails.
with patch(
"litellm.router._async_get_cooldown_deployments",
new=AsyncMock(return_value=["B"]),
):
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "fallback"