fix(logging): keep the proxy's own budget rejection an expected client error

The auth handler stamps the requested model's provider onto BudgetExceededError before logging it, which made a key-over-budget 429 look provider-originated and regain its traceback (and an OTel stack_trace) after the provider 4xx carve-out. Any exception whose unified rate-limit category names litellm's own limiter is now a proxy rejection, matching the HTTPException rule.
This commit is contained in:
mateo-berri 2026-08-25 20:51:55 -07:00
parent a6e1708e6c
commit 514cae1b3d
3 changed files with 51 additions and 1 deletions

View file

@ -58,7 +58,18 @@ def safe_divide(
return numerator / denominator
def _is_litellm_limit_rejection(exception: BaseException) -> bool:
from litellm.exceptions import RateLimitErrorCategory
litellm_limit_categories: Final = frozenset(
(RateLimitErrorCategory.LITELLM_RATE_LIMIT.value, RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT.value)
)
return getattr(exception, "category", None) in litellm_limit_categories
def _is_proxy_rejection(exception: BaseException) -> bool:
if _is_litellm_limit_rejection(exception):
return True
try:
from starlette.exceptions import HTTPException
except ImportError:
@ -85,7 +96,10 @@ def is_expected_client_error(exception: BaseException | None) -> bool:
``llm_provider``, and the raw ``BaseLLMException`` that provider handlers
raise before mapping (the /v1/messages route surfaces it as-is) is one too.
The proxy's own limiters raise ``HTTPException`` subclasses that also carry
an ``llm_provider``, so any ``HTTPException`` stays a proxy rejection.
an ``llm_provider``, so any ``HTTPException`` stays a proxy rejection, and
so does any exception whose unified rate-limit ``category`` names litellm's
own limiter (``BudgetExceededError`` is a plain ``Exception`` that the auth
handler decorates with the requested model's provider).
ProxyException stores the status on .code (as a str), HTTPException and
litellm exceptions on .status_code.

View file

@ -311,3 +311,26 @@ class TestIsExpectedClientError:
self.llm_provider = ""
assert is_expected_client_error(RouterRejection()) is True
def test_budget_rejection_decorated_with_provider_is_expected(self):
"""The auth handler stamps the requested model's provider onto the proxy's
own BudgetExceededError before logging it, which must not turn a key-over-budget
429 into a provider error that keeps its traceback."""
from litellm.exceptions import BudgetExceededError, RateLimitError, RateLimitErrorCategory
from litellm.litellm_core_utils.core_helpers import is_expected_client_error
over_budget = BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")
assert over_budget.llm_provider == "anthropic"
assert is_expected_client_error(over_budget) is True
litellm_limit = RateLimitError(
message="key over rpm", llm_provider="anthropic", model="claude-haiku-4-5",
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
)
assert is_expected_client_error(litellm_limit) is True
vendor_limit = RateLimitError(
message="rate limited upstream", llm_provider="anthropic", model="claude-haiku-4-5",
category=RateLimitErrorCategory.VENDOR_RATE_LIMIT,
)
assert is_expected_client_error(vendor_limit) is False

View file

@ -5746,6 +5746,19 @@ def test_get_error_information_keeps_traceback_for_unmapped_provider_4xx():
assert "test_litellm_logging" in result["traceback"]
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
handler stamps the requested model's provider onto it, so it stays cheap."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
assert litellm.log_client_error_tracebacks is False
over_budget = _raise_and_catch(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["llm_provider"] == "anthropic"
assert result["traceback"] == ""
def test_failure_handler_helper_fn_builds_payload_once_per_exception():
"""Regression for LIT-6043: async and sync failure handlers both call
_failure_handler_helper_fn for the same failed request; the standardized