Merge pull request #42097 from BerriAI/litellm_budget_exceeded_422

fix(proxy): return 422 instead of 429 for BudgetExceededError
This commit is contained in:
Yassin Kortam 2026-09-21 08:35:30 -05:00 • committed by GitHub
commit def37c6532
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 55 additions and 48 deletions

View file

@ -400,6 +400,7 @@ default_redis_batch_cache_expiry: Optional[float] = None
model_alias_map: Dict[str, str] = {}
model_group_settings: Optional["ModelGroupSettings"] = None
max_budget: float = 0.0 # set the max budget across all providers
budget_exceeded_status_code: int = 422 # set to 429 to restore the pre-422 budget_exceeded response code
budget_duration: Optional[str] = (
None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
)

View file

@ -16,6 +16,7 @@ from typing import Any, Final
import httpx
import openai
import litellm
from litellm.types.utils import LiteLLMCommonStrings
from litellm.types.vector_stores import VectorStoreSearchFailure
@ -1002,7 +1003,7 @@ class BudgetExceededError(Exception):
):
self.current_cost = current_cost
self.max_budget = max_budget
self.status_code = 429
self.status_code = litellm.budget_exceeded_status_code
self.llm_provider = llm_provider or ""
self.entity_type = entity_type
self.entity_id = entity_id

View file

@ -1154,7 +1154,7 @@ class MCPRequestHandler:
Failures surface with the status the standard pipeline would give them, mirroring
``UserAPIKeyAuthExceptionHandler``: a disallowed route is the route gate's own 403, an
over-budget identity is a 429, a sub-check that raised its own ``HTTPException``/
over-budget identity is a 422, a sub-check that raised its own ``HTTPException``/
``ProxyException`` keeps that status, a transient database outage is a retryable 503, and
only a genuinely unresolvable failure (a blocked team/project raises a bare ``Exception``,
same as the standard pipeline's fallback) becomes the fail-closed 401. Collapsing every

View file

@ -95,7 +95,7 @@ class UnauthorizedError(BaseModel):
class RateLimitedError(BaseModel):
kind: Literal["rate_limited"] = "rate_limited"
retry_after_seconds: int | None = None
# litellm overloads 429 for budget_exceeded too, so keep the body to tell them apart.
# keep the body so callers can tell limiter kinds apart.
body: str = ""

View file

@ -96,8 +96,8 @@ def _spend_until_budget_blocks(client: ManagementClient, key: str) -> None:
for _ in range(40):
outcome = client.chat_status(key, SPEND_MODEL, f"spend {unique_marker()}")
if _is_budget_block(outcome):
assert outcome.status_code == 429, (
f"budget refusal must be 429, got {outcome.status_code}: {outcome.body[:200]}"
assert outcome.status_code == 422, (
f"budget refusal must be 422, got {outcome.status_code}: {outcome.body[:200]}"
)
return
assert outcome.ok, f"paid call failed before the budget tripped ({outcome.status_code}): {outcome.body[:300]}"

View file

@ -46,10 +46,10 @@ def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") ->
pytest.fail("budget never enforced within the call budget")
def _assert_blocked_429(client: BudgetClient, key: str) -> StreamingResponse:
def _assert_blocked_422(client: BudgetClient, key: str) -> StreamingResponse:
blocked = _assert_budget_blocks(client, key)
assert blocked.status_code == 429, (
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
assert blocked.status_code == 422, (
f"budget refusal must be 422, got {blocked.status_code}: {blocked.body[:200]}"
)
return blocked
@ -60,7 +60,7 @@ class TestBudgetBlocksPerLevel:
key = client.generate_key(max_budget=TINY_CAP)
resources.defer(lambda: client.delete_key(key))
_assert_blocked_429(client, key)
_assert_blocked_422(client, key)
@pytest.mark.covers("quota_management.budget.team.blocks_over_limit")
def test_team_budget_blocks_every_team_key(self, client: BudgetClient, resources: ResourceManager) -> None:
@ -71,10 +71,10 @@ class TestBudgetBlocksPerLevel:
sibling_key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(sibling_key))
_assert_blocked_429(client, spender_key)
_assert_blocked_422(client, spender_key)
sibling = _chat(client, sibling_key)
assert is_budget_block(sibling) and sibling.status_code == 429, (
f"a sibling key on the capped team must get the same 429 budget_exceeded, "
assert is_budget_block(sibling) and sibling.status_code == 422, (
f"a sibling key on the capped team must get the same 422 budget_exceeded, "
f"got {sibling.status_code}: {sibling.body[:200]}"
)
@ -99,10 +99,10 @@ class TestBudgetBlocksPerLevel:
team_key = client.generate_key(team_id=team_id, user_id=user_id)
resources.defer(lambda: client.delete_key(team_key))
_assert_blocked_429(client, first_key)
_assert_blocked_422(client, first_key)
second = _chat(client, second_key)
assert is_budget_block(second) and second.status_code == 429, (
f"the second personal key of a user over budget must get the same 429 budget_exceeded, "
assert is_budget_block(second) and second.status_code == 422, (
f"the second personal key of a user over budget must get the same 422 budget_exceeded, "
f"got {second.status_code}: {second.body[:200]}"
)
team_result = _chat(client, team_key)
@ -133,7 +133,7 @@ class TestBudgetBlocksPerLevel:
key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(key))
blocked = _assert_blocked_429(client, key)
blocked = _assert_blocked_422(client, key)
assert f"Organization={org_id}" in blocked.body, (
f"refusal must name the org as the blocker, got: {blocked.body[:200]}"
)
@ -155,7 +155,7 @@ class TestBudgetBlocksPerLevel:
teammate_key = client.generate_key(team_id=team_id, user_id=teammate_id)
resources.defer(lambda: client.delete_key(teammate_key))
_assert_blocked_429(client, member_key)
_assert_blocked_422(client, member_key)
require_successful_call(_chat(client, teammate_key))
@ -176,7 +176,7 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
control_key = client.generate_key(user_id=user_id)
resources.defer(lambda: client.delete_key(control_key))
_assert_blocked_429(client, capped_key)
_assert_blocked_422(client, capped_key)
require_successful_call(_chat(client, control_key))
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
@ -188,7 +188,7 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
control_key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(control_key))
_assert_blocked_429(client, capped_key)
_assert_blocked_422(client, capped_key)
require_successful_call(_chat(client, control_key))
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
@ -205,5 +205,5 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
control_key = client.generate_key(team_id=team_id, user_id=member_id)
resources.defer(lambda: client.delete_key(control_key))
_assert_blocked_429(client, capped_key)
_assert_blocked_422(client, capped_key)
require_successful_call(_chat(client, control_key))

View file

@ -102,7 +102,7 @@ def test_long_window_blocks_after_short_window_resets(client: BudgetClient, reso
# 1. drive the key to get blocked by SHORT_WINDOW, assert it's budget error
blocked = _drive_to_block(client, key)
assert blocked.status_code == 429, f"budget block was not a 429: {blocked.status_code} {blocked.body[:200]}"
assert blocked.status_code == 422, f"budget block was not a 422: {blocked.status_code} {blocked.body[:200]}"
# 2. check the reset times of both budget windows after we drove to being blocked
blocked_reset_at = window_reset_at(client.key_budget_windows(key), SHORT_WINDOW)

View file

@ -101,7 +101,7 @@ def test_team_long_window_blocks_after_short_window_resets(client: BudgetClient,
# 1. drive the key to being blocked, assert its blocked by budget budget_exceeded
blocked = _drive_to_block(client, key)
assert blocked.status_code == 429, f"budget block was not a 429: {blocked.status_code} {blocked.body[:200]}"
assert blocked.status_code == 422, f"budget block was not a 422: {blocked.status_code} {blocked.body[:200]}"
# 2. check the the teams budget windows
blocked_reset_at = window_reset_at(client.team_budget_windows(team_id), SHORT_WINDOW)

View file

@ -97,7 +97,7 @@ def test_zero_false_and_empty_values_are_not_treated_as_omission(gateway: Gatewa
"POST", "/v1/chat/completions",
{"model": models[0], "messages": [{"role": "user", "content": "zero budget"}]}, key=key,
)
assert denied.status_code == 429, denied.text
assert denied.status_code == 422, denied.text
assert denied.json()["error"]["type"] == "budget_exceeded"
gateway.post("/key/update", {"key": key, "max_budget": 1, "models": [], "metadata": {}})
info: Final = object_value(gateway.get("/key/info", {"key": key})["info"])
@ -127,7 +127,7 @@ def test_zero_false_and_empty_values_are_not_treated_as_omission(gateway: Gatewa
"POST", "/v1/chat/completions",
{"model": models[0], "messages": [{"role": "user", "content": "updated zero budget"}]}, key=key,
)
assert zero_after_update.status_code == 429, zero_after_update.text
assert zero_after_update.status_code == 422, zero_after_update.text
assert zero_after_update.json()["error"]["type"] == "budget_exceeded"
gateway.post("/key/update", {"key": key, "max_budget": None})
assert read_rows(

View file

@ -185,7 +185,7 @@ def test_key_budget_at_boundary_blocks_provider_then_explicit_reset_restores(gat
{"model": model, "messages": [{"role": "user", "content": f"over budget {uuid.uuid4().hex}"}]},
key=key,
)
assert denied.status_code == 429 and denied.json()["error"]["type"] == "budget_exceeded", denied.text
assert denied.status_code == 422 and denied.json()["error"]["type"] == "budget_exceeded", denied.text
assert upstream.get("/__observations").json()["requests"] == []
assert gateway.chat(model, key=control, text=f"control {uuid.uuid4().hex}")["usage"]["total_tokens"] == 40
gateway.post("/key/update", {"key": key, "spend": 0})
@ -205,7 +205,7 @@ def test_key_budget_at_boundary_blocks_provider_then_explicit_reset_restores(gat
{"model": model, "messages": [{"role": "user", "content": f"boundary again {uuid.uuid4().hex}"}]},
key=key,
)
assert denied_again.status_code == 429 and denied_again.json()["error"]["type"] == "budget_exceeded", (
assert denied_again.status_code == 422 and denied_again.json()["error"]["type"] == "budget_exceeded", (
denied_again.text
)
assert upstream.get("/__observations").json()["requests"] == []

View file

@ -3033,7 +3033,7 @@ def test_get_error_information_budget_exceeded_structured_fields():
assert result["error_budget_entity_id"] == "repro-user"
assert result["error_budget_limit"] == 1e-06
assert result["error_budget_spend"] == 3.4e-05
assert result["error_code"] == "429"
assert result["error_code"] == "422"
assert result["error_class"] == "BudgetExceededError"
assert result["error_rate_limit_type"] == "budget"
@ -6407,7 +6407,7 @@ def test_get_error_information_keeps_traceback_for_unmapped_provider_4xx():
def test_get_error_information_skips_traceback_for_budget_rejection_with_provider():
"""A key-over-budget 429 is the proxy's own rejection even after the auth
"""A key-over-budget 422 is the proxy's own rejection even after the auth
handler stamps the requested model's provider onto it, so it stays cheap."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
@ -6416,7 +6416,7 @@ def test_get_error_information_skips_traceback_for_budget_rejection_with_provide
litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")
)
result = StandardLoggingPayloadSetup.get_error_information(over_budget)
assert result["error_code"] == "429"
assert result["error_code"] == "422"
assert result["llm_provider"] == "anthropic"
assert result["traceback"] == ""

View file

@ -6339,15 +6339,15 @@ class TestMCPDcrBridgeDelegateAdmission:
)
return exc_info.value
async def test_over_budget_admission_surfaces_429_not_401(self):
"""A validly-authenticated but over-budget identity surfaces the standard pipeline's 429, not
async def test_over_budget_admission_surfaces_422_not_401(self):
"""A validly-authenticated but over-budget identity surfaces the standard pipeline's 422, not
a misleading 401. Flattening budget to 401 told the caller their credential was invalid, which
on a DCR client reads as broken auth and triggers a re-authorize that cannot fix a budget
problem. Regression for the status-flattening finding on the live-policy gate."""
import litellm
mapped = await self._enforce_with_gate_error(litellm.BudgetExceededError(current_cost=10.0, max_budget=1.0))
assert mapped.status_code == 429
assert mapped.status_code == 422
async def test_db_outage_during_policy_surfaces_503_not_401(self):
"""A transient database outage during the live-policy gate surfaces a retryable 503, not a 401

View file

@ -448,7 +448,7 @@ async def test_handle_authentication_error_budget_exceeded():
)
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
assert int(exc_info.value.code) == status.HTTP_429_TOO_MANY_REQUESTS
assert int(exc_info.value.code) == status.HTTP_422_UNPROCESSABLE_CONTENT
@pytest.mark.asyncio
@ -687,7 +687,7 @@ def _http_request(client_host: str | None = "10.1.2.3", headers: dict[str, str]
{"allow_requests_on_db_unavailable": False},
{},
"10.1.2.3",
id="429_budget_exceeded",
id="422_budget_exceeded",
),
],
)
@ -697,7 +697,7 @@ async def test_auth_failure_logs_requester_ip_address(
request_kwargs: dict[str, dict[str, str]],
expected_ip: str,
) -> None:
"""401s and budget 429s are rejected before `add_litellm_data_to_request` stamps
"""401s and budget 422s are rejected before `add_litellm_data_to_request` stamps
the caller IP, so without this the failure logs (spend logs, prometheus client_ip)
had no IP, and a 401 rarely carries a key or user identity either."""
with (

View file

@ -75,7 +75,7 @@ async def test_over_first_window_raises():
await _virtual_key_multi_budget_check(valid_token=token)
err = exc_info.value
assert err.status_code == 429
assert err.status_code == 422
assert "24h" in str(err)
assert "Key over" in str(err)
@ -107,7 +107,7 @@ async def test_over_second_window_raises():
await _virtual_key_multi_budget_check(valid_token=token)
err = exc_info.value
assert err.status_code == 429
assert err.status_code == 422
assert "30d" in str(err)

View file

@ -8156,7 +8156,7 @@ async def test_reset_key_spend_resets_budget_windows(monkeypatch):
counter without also advancing reset_at is not durable either: the very
next request would re-sum the unchanged historical spend and put the
counter right back above the window's max_budget, so
_virtual_key_multi_budget_check kept raising BudgetExceededError (429) on
_virtual_key_multi_budget_check kept raising BudgetExceededError (422) on
every request even though the key's own reported spend read $0.
"""
mock_prisma_client = MagicMock()
@ -16593,7 +16593,7 @@ async def test_info_key_fn_reads_the_configured_budget_model_key(monkeypatch):
It used to probe a second, provider-stripped key because the counter was
written under the request model instead, which is what let a key report zero
usage while being blocked at 429.
usage while being blocked at 422.
"""
from unittest.mock import AsyncMock, MagicMock

View file

@ -2099,7 +2099,7 @@ class TestCursorVariantPerModelBudgetEnforcement:
response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-thinking-high")
assert response.status_code == 429, response.text
assert response.status_code == 422, response.text
error = response.json()["error"]
assert error["type"] == "budget_exceeded"
assert "exceeded budget for model=claude-opus-5" in error["message"]
@ -2110,8 +2110,8 @@ class TestCursorVariantPerModelBudgetEnforcement:
base_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5")
alias_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-fast")
assert base_response.status_code == 429, base_response.text
assert alias_response.status_code == 429, alias_response.text
assert base_response.status_code == 422, base_response.text
assert alias_response.status_code == 422, alias_response.text
assert alias_response.json() == base_response.json()

View file

@ -495,7 +495,7 @@ class TestProxyBaseLLMRequestProcessing:
)
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
assert exc_info.value.code == "429"
assert exc_info.value.code == "422"
tag_budget_check.assert_awaited_once()
_, call_kwargs = tag_budget_check.call_args
assert call_kwargs["tags"] == ("guardrail-tag",)
@ -702,7 +702,7 @@ class TestProxyBaseLLMRequestProcessing:
)
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
assert exc_info.value.code == "429"
assert exc_info.value.code == "422"
assert "guardrail-tag" in exc_info.value.message
@pytest.mark.asyncio

View file

@ -10872,7 +10872,7 @@ async def test_realtime_session_rejected_in_pre_call_releases_the_budget_reserva
"""A rate-limit or guardrail rejection happens before route_request, so the
relay never runs and no success log can own the reservation. The endpoint
must release it on that exit too, or the key stays pinned at the reserved
amount and its next requests 429 with budget_exceeded while /key/info shows
amount and its next requests 422 with budget_exceeded while /key/info shows
spend 0 (reproduced live with rpm_limit=1). The client still gets the
pre-call error event and the 1011 close it got before."""
reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []}

View file

@ -1397,13 +1397,18 @@ class TestBudgetExceededErrorSurfacesUnifiedFields:
assert e.llm_provider == "anthropic"
def test_should_keep_existing_status_code_and_message(self):
# Backward-compat guard: existing callers depend on `status_code=429`
# Backward-compat guard: existing callers depend on `status_code=422`
# and the canonical message format.
e = litellm.BudgetExceededError(current_cost=0.000109, max_budget=0.0001)
assert e.status_code == 429
assert e.status_code == 422
assert "Current cost: 0.000109" in e.message
assert "Max budget: 0.0001" in e.message
def test_should_honor_budget_exceeded_status_code_override(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(litellm, "budget_exceeded_status_code", 429)
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
assert e.status_code == 429
def test_should_still_be_catchable_as_exception_not_rate_limit_error(self):
# Critical: we deliberately did NOT make BudgetExceededError a
# RateLimitError subclass. Existing `except BudgetExceededError:`
@ -1424,7 +1429,7 @@ class TestBudgetExceededErrorSurfacesUnifiedFields:
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["error_rate_limit_category"] == "litellm_rate_limit"
assert info["error_rate_limit_type"] == "budget"
assert info["error_code"] == "429"
assert info["error_code"] == "422"
assert info["error_class"] == "BudgetExceededError"
def test_should_propagate_llm_provider_to_standard_logging_payload(self):