fix(rate-limit): surface unified category/type fields on BudgetExceededError

The most common budget cap (virtual-key max_budget enforcement in
auth_checks.py) raises litellm.BudgetExceededError, a bare Exception
subclass that bypassed the unified rate-limit error class introduced
by PR #27687. Custom callbacks reading
StandardLoggingPayload.error_information saw category=None and
rate_limit_type=None for these 429s, missing the most common budget
case (team / org / end-user budgets all hit the same code path).

Surface the fields off BudgetExceededError as plain attributes:
- category = RateLimitErrorCategory.LITELLM_RATE_LIMIT
- rate_limit_type = RateLimitType.BUDGET
- llm_provider = "" (or caller-supplied)

Switch get_error_information and _extract_rate_limit_labels from
isinstance(RateLimitError) gating to duck-typed attribute reads,
guarded by membership in the rate-limit enums so unrelated third-party
exceptions exposing a .category attribute can't leak garbage values
into the payload.

This is strictly additive: BudgetExceededError keeps its bare-Exception
base class, so `except BudgetExceededError:` handlers keep firing and
`except RateLimitError:` does not start catching budget errors.
This commit is contained in:
mateo-berri 2026-05-13 21:29:18 -07:00
parent 7e8a0779da
commit 807d1b29d6
5 changed files with 135 additions and 23 deletions

View file

@ -956,11 +956,24 @@ LITELLM_EXCEPTION_TYPES = [
class BudgetExceededError(Exception):
def __init__(
self, current_cost: float, max_budget: float, message: Optional[str] = None
self,
current_cost: float,
max_budget: float,
message: Optional[str] = None,
llm_provider: Optional[str] = None,
):
self.current_cost = current_cost
self.max_budget = max_budget
self.status_code = 429
self.llm_provider = llm_provider or ""
# Surface unified rate-limit fields without joining the RateLimitError
# hierarchy so existing `except BudgetExceededError:` handlers keep
# working; custom callbacks reading StandardLoggingPayload pick these
# up via the same `category` / `rate_limit_type` attributes the rest
# of the unified rate-limit error path uses. Stored as plain strings
# to match the normalization RateLimitError.__init__ performs.
self.category: str = RateLimitErrorCategory.LITELLM_RATE_LIMIT.value
self.rate_limit_type: str = RateLimitType.BUDGET.value
message = (
message
or f"Budget has been exceeded! Current cost: {current_cost}, Max budget: {max_budget}"

View file

@ -2685,15 +2685,16 @@ class PrometheusLogger(CustomLogger):
) -> Tuple[Optional[str], Optional[str]]:
"""
Pull the unified ``category`` / ``rate_limit_type`` fields off any
:class:`litellm.RateLimitError` (vendor-side or litellm-internal) so
Prometheus can split 429s by source + dimension without the consumer
parsing free-text error messages.
exception that declares them (``litellm.RateLimitError`` and bare-
Exception subclasses like ``BudgetExceededError`` that set these
attributes directly) so Prometheus can split 429s by source +
dimension without the consumer parsing free-text error messages.
Returns ``(None, None)`` for non-rate-limit exceptions. Both values are
sanitized to plain ``str`` so str-enum subclasses don't leak their
repr into the label.
Returns ``(None, None)`` for exceptions that don't declare these
fields. Both classes normalize their values to plain ``str`` at
construction, so this helper only needs to coerce defensively.
"""
if exception is None or not isinstance(exception, litellm.RateLimitError):
if exception is None:
return None, None
def _coerce(value: Any) -> Optional[str]:

View file

@ -5159,22 +5159,18 @@ class StandardLoggingPayloadSetup:
# Get additional error details
error_message = str(original_exception)
# For rate-limit errors (litellm.RateLimitError + the proxy-side
# ProxyRateLimitError subclass), surface the unified `category` and
# `rate_limit_type` fields so callbacks can distinguish vendor vs.
# litellm rate limits AND split by the dimension that was exceeded
# (requests / tokens / concurrent_requests / budget / max_iterations)
# without reaching for the raw exception object.
is_rate_limit_error = isinstance(original_exception, litellm.RateLimitError)
rate_limit_category: Optional[str] = (
getattr(original_exception, "category", None)
if is_rate_limit_error
else None
# Surface the unified `category` and `rate_limit_type` fields off any
# exception that opts in by setting them. Duck-typed rather than
# isinstance-gated on RateLimitError so bare-Exception subclasses like
# `litellm.BudgetExceededError` can participate without joining the
# RateLimitError hierarchy (which would break `except BudgetExceededError`).
# Both RateLimitError and BudgetExceededError normalize their values to
# plain strings at construction.
rate_limit_category: Optional[str] = getattr(
original_exception, "category", None
)
rate_limit_type: Optional[str] = (
getattr(original_exception, "rate_limit_type", None)
if is_rate_limit_error
else None
rate_limit_type: Optional[str] = getattr(
original_exception, "rate_limit_type", None
)
return StandardLoggingPayloadErrorInformation(

View file

@ -94,6 +94,20 @@ def test_should_return_none_for_none_exception():
assert PrometheusLogger._extract_rate_limit_labels(None) == (None, None)
def test_should_extract_budget_dimension_for_budget_exceeded_error():
# Virtual-key / team / org / end-user budget caps raise
# `litellm.BudgetExceededError` (a bare Exception subclass), which sets
# the same `.category` / `.rate_limit_type` attributes as the unified
# RateLimitError path so Prometheus can split budget 429s from other
# 429s without the customer parsing free-text error messages.
import litellm
err = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err)
assert category == "litellm_rate_limit"
assert rate_limit_type == "budget"
@pytest.mark.parametrize(
"category_enum,rate_limit_enum,expected_category,expected_type",
[

View file

@ -1419,3 +1419,91 @@ class TestProxyHooksWireTypeCorrectly:
e = exc_info.value
assert e.rate_limit_type == "requests"
assert e.category == RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
class TestBudgetExceededErrorSurfacesUnifiedFields:
"""
The hot path for virtual-key / team / org / end-user max_budget caps
raises :class:`litellm.BudgetExceededError`, which historically had no
relationship to :class:`RateLimitError` and therefore left the unified
`error_rate_limit_category` / `error_rate_limit_type` fields empty.
Test 2 of the QA pass surfaced this gap; this class pins the fix.
The fix is intentionally additive: `BudgetExceededError` keeps its
bare-`Exception` base class (so existing `except BudgetExceededError:`
handlers keep working) and just sets the same `category` /
`rate_limit_type` attributes that the rest of the unified rate-limit
path reads (normalized to plain strings, matching how
`RateLimitError.__init__` stores its own values). Duck-typed dispatch
in `get_error_information` picks them up automatically.
"""
def test_should_carry_litellm_rate_limit_category(self):
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
# Stored as the plain string value (matches RateLimitError behavior),
# but equality with the enum still works because the enum subclasses
# str.
assert e.category == "litellm_rate_limit"
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
def test_should_carry_budget_rate_limit_type(self):
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
assert e.rate_limit_type == "budget"
assert e.rate_limit_type == RateLimitType.BUDGET
def test_should_default_llm_provider_to_empty_string(self):
# `llm_provider` is read off the exception in `get_error_information`
# — it must always be a string so the StandardLoggingPayload field
# stays serializable. Default to "" when no caller passes one.
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
assert e.llm_provider == ""
def test_should_accept_llm_provider_kwarg(self):
# Callers that have the resolved provider in scope (e.g. the
# auth-checks budget enforcement paths) can thread it through.
e = litellm.BudgetExceededError(
current_cost=0.5, max_budget=0.1, llm_provider="anthropic"
)
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`
# and the canonical message format.
e = litellm.BudgetExceededError(current_cost=0.000109, max_budget=0.0001)
assert e.status_code == 429
assert "Current cost: 0.000109" in e.message
assert "Max budget: 0.0001" in e.message
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:`
# handlers must keep catching it, and `except RateLimitError:`
# handlers must NOT start catching it (which would surprise callers
# who rely on the two being distinct).
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
assert isinstance(e, Exception)
assert isinstance(e, litellm.BudgetExceededError)
assert not isinstance(e, RateLimitError)
def test_should_propagate_category_to_standard_logging_payload(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
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_class"] == "BudgetExceededError"
def test_should_propagate_llm_provider_to_standard_logging_payload(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
e = litellm.BudgetExceededError(
current_cost=0.5, max_budget=0.1, llm_provider="bedrock"
)
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["llm_provider"] == "bedrock"