litellm/tests/test_litellm/test_router_retry_policy_update.py
ryan-crabbe-berri 234263fdda
fix(router): persist global retry_policy via /config/update (#29540)
* fix(router): persist global retry_policy via /config/update (LIT-3152)

The Admin UI Model Retry Settings tab POSTs
{router_settings: {retry_policy: {...}}} to /config/update, but the
field was dropped on two write-side layers so it never reached the
router. UpdateRouterConfig did not declare retry_policy, so
dict(exclude_none=True) stripped it before the DB upsert. And even when
fed directly, Router.update_settings had no "retry_policy" entry in
_allowed_settings, so the assignment was a silent no-op. The DB row
stayed at {"model_group_alias": {}}, llm_router.retry_policy stayed
None, and the UI fell back to defaultRetry = num_retries = 2 on refresh.

Declare retry_policy on UpdateRouterConfig as a plain dict, and add a
retry_policy branch to update_settings that coerces dict payloads to
RetryPolicy before setattr, mirroring Router.__init__. get_settings
already lists retry_policy, so reads work once writes land.

* fix(router): guard retry_policy type in update_settings

Mirror Router.__init__ semantics in update_settings: only assign
retry_policy when it is None or a RetryPolicy (after dict coercion).
Previously a non-dict, non-RetryPolicy value (e.g. a YAML typo like
retry_policy: 5 flowing through /config/update) was stored verbatim,
deferring the failure to request time in get_num_retries_from_retry_policy
instead of being dropped at write time.

* refactor(ui): harden Model Retry Settings flow and validate retry_policy at the boundary

Types UpdateRouterConfig.retry_policy as RetryPolicy and model_group_retry_policy as Dict[str, RetryPolicy] so /config/update validates the payload and rejects malformed counts instead of silently persisting them; the apply path in update_settings keeps coercing the stored dict back to RetryPolicy

Makes the Model Retry Settings tab the single owner of retry_policy and model_group_retry_policy so the generic Router Settings page no longer renders or writes them, replaces the fire-and-forget save with a react-query mutation that only shows the success toast after the write resolves, surfaces real errors, disables Save while in flight, and re-reads authoritative state on success, and sends both the global and per-group policies atomically so edits in the inactive scope are no longer dropped

Decouples the retry-scope selector from the All Models filter and defaults it to Global, seeds the displayed default from num_retries (falling back to 2), and gives per-group rows real inherit semantics so an empty input shows the global value as a placeholder with a Reset control, keeping 0 ("no retries") distinct from inheriting the global value

* fix(keys): align router_settings examples with typed RetryPolicy and resync UI artifacts

model_group_retry_policy is now Dict[str, RetryPolicy], so the {"max_retries": 5} sample in the key-generate test and the /key/generate and /key/update docstrings no longer validate; they now use a valid {"gpt-4": {"RateLimitErrorRetries": 5}} shape.

Regenerated eslint-metrics.json (no-explicit-any drifted 2027 -> 2026) and schema.d.ts (new RetryPolicy schema, retry_policy field, model_group_retry_policy value type) so the UI build and api-types-sync checks pass

* test(router): pin retry_policy persistence end to end (LIT-3152)

The existing retry_policy tests exercise UpdateRouterConfig and Router.update_settings in isolation, so they would all still pass if a regression flipped ConfigYAML.router_settings back to a loose dict or stopped add_deployment from applying the stored row. This drives the real handler chain an Admin UI save triggers: update_config writes the LiteLLM_Config row, the apply path forwards it to the live router, and get_config serializes it back, pinning retry_policy across persist, apply, and read-back.

* fix(teams): use valid model_group_retry_policy example in router_settings docstring

Same stale {"max_retries": 5} example the key endpoints carried; model_group_retry_policy maps a model group to a RetryPolicy, so the team /team/new and /team/update docs now show {"gpt-4": {"RateLimitErrorRetries": 5}}. Regenerated schema.d.ts to match.

* fix(ui): load retry settings via deferred fetch to satisfy set-state-in-effect

The Model Retry Settings effect called loadRetrySettings synchronously; eslint-plugin-react-hooks (react-hooks/set-state-in-effect) traces into it and flags the setState calls, failing frontend-lint. Split the loader into fetchRouterSettings + applyRouterSettings and run the fetch in an inline async IIFE with a cancellation flag, so state is applied in the post-await callback rather than on the effect's synchronous path. Behavior is unchanged and onSuccess still refreshes via loadRetrySettings.

* fix(ui): match CI rendering of RateLimitError 429 docstring in generated schema

gen:api run on a dev env (python 3.13 / newer fastapi) rendered the RateLimitError response description with 4-space indentation, but CI regenerates it with 8-space under its frozen python 3.12 toolchain, which is the canonical committed form. The Check UI API Types Sync job regenerates and diffs, so restore that block to the CI rendering; verified byte-identical to the pre-existing committed version.

* fix(ui): pin RateLimitError 429 docstring to CI's frozen schema rendering

Base #29619 regenerated schema.d.ts on a newer FastAPI that renders the RateLimitError response description at 4-space indent, but the Check UI API Types Sync job regenerates under the frozen python 3.12 toolchain, which renders 8-space. Merging base pulled in the 4-space form; restore the 8-space rendering so the generated types match what CI produces (verified byte-identical to the pre-#29619 committed form), which also corrects the base drift once this PR merges.
2026-06-28 00:20:20 +00:00

279 lines
11 KiB
Python

"""
Tests for the retry_policy fix on Router.update_settings (LIT-3152).
Bug: the Admin UI Model Retry Settings tab posts a global ``retry_policy``
through ``POST /config/update`` -> ``UpdateRouterConfig`` ->
``Router.update_settings``. Both the pydantic schema and
``update_settings`` were dropping the field silently:
- ``UpdateRouterConfig`` had no ``retry_policy`` attribute, so
``model_dump(exclude_none=True)`` returned ``{}`` for that key.
- ``Router.update_settings`` had no ``"retry_policy"`` entry in
``_allowed_settings``, so even when fed directly the call was a no-op
(``Setting {} is not allowed`` debug log).
The net effect was that after saving retry counts in the UI and
reloading, every value snapped back to ``defaultRetry = num_retries``
(2 by default), exactly matching the ticket repro.
This file pins both halves of the fix.
"""
import json
import os
import sys
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock
import pytest
from pydantic import ValidationError
sys.path.insert(0, os.path.abspath("../.."))
import litellm
from litellm.types.router import RetryPolicy, UpdateRouterConfig
# ---------------------------------------------------------------------------
# UpdateRouterConfig schema membership (LIT-3152 part 1)
# ---------------------------------------------------------------------------
def test_update_router_config_exposes_retry_policy_field():
"""retry_policy must be a declared field on UpdateRouterConfig.
Without it, Pydantic silently strips the key from the /config/update
payload before the proxy even calls llm_router.update_settings.
"""
assert "retry_policy" in UpdateRouterConfig.model_fields
def test_update_router_config_accepts_retry_policy_payload():
"""The exact payload the Admin UI Model Retry Settings tab sends must
round-trip through the schema's ``dict(exclude_none=True)`` form, since
that is what /config/update writes to the LiteLLM_Config row."""
payload = {
"retry_policy": {
"BadRequestErrorRetries": 5,
"RateLimitErrorRetries": 7,
"TimeoutErrorRetries": 3,
}
}
cfg = UpdateRouterConfig(**payload)
dumped = cfg.model_dump(exclude_none=True)
assert "retry_policy" in dumped
assert dumped["retry_policy"]["BadRequestErrorRetries"] == 5
assert dumped["retry_policy"]["RateLimitErrorRetries"] == 7
assert dumped["retry_policy"]["TimeoutErrorRetries"] == 3
def test_update_router_config_rejects_malformed_retry_policy():
"""The field is typed as RetryPolicy, so /config/update validates the
payload at the boundary and rejects non-numeric counts with a 422 instead
of silently persisting garbage the apply path would later have to drop."""
with pytest.raises(ValidationError):
UpdateRouterConfig(retry_policy={"BadRequestErrorRetries": "not-an-int"})
def test_update_router_config_rejects_malformed_model_group_retry_policy():
"""model_group_retry_policy is Dict[str, RetryPolicy], so each per-group
policy is validated the same way."""
with pytest.raises(ValidationError):
UpdateRouterConfig(
model_group_retry_policy={"gpt-4": {"RateLimitErrorRetries": "x"}}
)
# ---------------------------------------------------------------------------
# Router.update_settings retry_policy path (LIT-3152 part 2)
# ---------------------------------------------------------------------------
def _build_router() -> litellm.Router:
return litellm.Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "sk-fake",
"api_base": "http://localhost:9999",
},
}
]
)
def test_update_settings_persists_retry_policy_dict():
"""When the proxy's ``_add_router_settings_from_db_config`` calls
``llm_router.update_settings(retry_policy={...})`` after reading the
DB row, the dict must land on ``self.retry_policy`` as a typed
``RetryPolicy`` (mirroring ``Router.__init__`` semantics)."""
router = _build_router()
assert router.retry_policy is None # baseline
router.update_settings(
retry_policy={
"BadRequestErrorRetries": 5,
"RateLimitErrorRetries": 7,
"TimeoutErrorRetries": 3,
}
)
assert isinstance(router.retry_policy, RetryPolicy)
assert router.retry_policy.BadRequestErrorRetries == 5
assert router.retry_policy.RateLimitErrorRetries == 7
assert router.retry_policy.TimeoutErrorRetries == 3
def test_update_settings_accepts_retry_policy_object_unchanged():
"""A pre-built ``RetryPolicy`` instance must pass through verbatim so
callers that already constructed one (e.g. tests or programmatic
callers) keep working."""
router = _build_router()
policy = RetryPolicy(BadRequestErrorRetries=2)
router.update_settings(retry_policy=policy)
assert router.retry_policy is policy
def test_update_settings_ignores_malformed_retry_policy():
"""A non-dict, non-``RetryPolicy`` value (e.g. a YAML typo like
``retry_policy: 5`` reaching ``update_settings``) must not land on
``self.retry_policy``. ``Router.__init__`` already drops such inputs;
the update path must match so a malformed config can't store garbage
that ``get_num_retries_from_retry_policy`` would only choke on at
request time."""
router = _build_router()
existing = RetryPolicy(BadRequestErrorRetries=4)
router.update_settings(retry_policy=existing)
assert router.retry_policy is existing
for bad_value in (5, "RateLimitErrorRetries=7", ["BadRequestErrorRetries"]):
router.update_settings(retry_policy=bad_value)
assert router.retry_policy is existing
def test_update_settings_get_settings_round_trip_for_retry_policy():
"""``GET /get/config/callbacks`` serializes ``llm_router.get_settings()``
back to the UI. After updating, the round-trip must reflect the new
values rather than the pre-update sentinel."""
router = _build_router()
pre = router.get_settings().get("retry_policy")
assert pre is None
router.update_settings(
retry_policy={
"BadRequestErrorRetries": 5,
"RateLimitErrorRetries": 7,
}
)
post = router.get_settings().get("retry_policy")
assert post is not None
assert post.BadRequestErrorRetries == 5
assert post.RateLimitErrorRetries == 7
def test_update_settings_unrelated_kwargs_still_skipped():
"""Regression guard: the new branch must not relax the
``_allowed_settings`` allowlist for unrelated keys. An unknown
setting should still be dropped silently as before."""
router = _build_router()
router.update_settings(this_is_not_a_router_setting=123)
assert not hasattr(router, "this_is_not_a_router_setting")
# ---------------------------------------------------------------------------
# End-to-end persist -> apply -> read-back (LIT-3152 part 3)
#
# The tests above pin each layer in isolation, so they would all still pass
# if a regression flipped ``ConfigYAML.router_settings`` back to a loose
# ``dict`` (silently dropping retry_policy on the DB write) or if
# ``_add_router_settings_from_db_config`` stopped pushing the stored row onto
# the live router. This drives the real handler chain an Admin UI save
# triggers — ``POST /config/update`` writes the LiteLLM_Config row,
# ``add_deployment`` applies it to ``llm_router``, and
# ``GET /get/config/callbacks`` serializes it back — so the round trip is
# pinned, not just the pieces.
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class _FakeConfigRow:
param_name: str
param_value: dict
class _FakeConfigTable:
"""Stand-in for ``prisma_client.db.litellm_config``.
Reproduces the one behavior the apply path relies on: a value written as a
JSON string by ``/config/update`` reads back as a parsed dict, which is
what ``_add_router_settings_from_db_config``'s
``isinstance(param_value, dict)`` branch requires to forward the settings.
"""
def __init__(self):
self.rows = {}
async def find_first(self, where):
return self.rows.get(where["param_name"])
async def upsert(self, where, data):
name = where["param_name"]
raw = (data["update"] if name in self.rows else data["create"])["param_value"]
self.rows[name] = _FakeConfigRow(name, json.loads(raw) if isinstance(raw, str) else raw)
@pytest.mark.asyncio
async def test_config_update_persists_and_reads_back_retry_policy(monkeypatch):
"""The exact global retry_policy save the UI performs must survive the
real ``/config/update`` -> DB -> apply -> ``/get/config/callbacks`` path,
not snap back to the ``num_retries`` fallback the ticket reported."""
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import ConfigYAML, LitellmUserRoles, UserAPIKeyAuth
router = _build_router()
assert router.retry_policy is None
fake_table = _FakeConfigTable()
prisma_client = MagicMock()
prisma_client.db.litellm_config = fake_table
async def _apply_router_settings(*args, **kwargs):
await proxy_server.proxy_config._add_router_settings_from_db_config(
config_data={}, llm_router=router, prisma_client=prisma_client
)
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server.proxy_config, "add_deployment", _apply_router_settings)
monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
posted = UpdateRouterConfig(
retry_policy=RetryPolicy(
BadRequestErrorRetries=5,
TimeoutErrorRetries=3,
RateLimitErrorRetries=7,
)
)
await proxy_server.update_config(
config_info=ConfigYAML(router_settings=posted),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234"),
)
persisted = fake_table.rows["router_settings"].param_value["retry_policy"]
assert persisted == {
"BadRequestErrorRetries": 5,
"TimeoutErrorRetries": 3,
"RateLimitErrorRetries": 7,
}
assert isinstance(router.retry_policy, RetryPolicy)
assert router.retry_policy.RateLimitErrorRetries == 7
read_back = (await proxy_server.get_config())["router_settings"]["retry_policy"]
assert read_back.BadRequestErrorRetries == 5
assert read_back.TimeoutErrorRetries == 3
assert read_back.RateLimitErrorRetries == 7