Files
litellm/tests/test_litellm/test_rate_limit_error_unification.py
T
Mateo Wang 13924fa1d6 feat: standardize rate limit errors with category, rate_limit_type, model, and llm_provider fields (#27687)
* feat(exceptions): add RateLimitErrorCategory + headers/detail fields on RateLimitError

LiteLLM previously surfaced rate-limit conditions through several unrelated
error classes (RateLimitError, FastAPI HTTPException(429), BaseLLMException).
This commit adds the data model needed to consolidate them under a single
class:

* RateLimitErrorCategory enum exposing four categorical values
  (vendor_rate_limit, vendor_batch_rate_limit, litellm_rate_limit,
  litellm_batch_rate_limit) so callers can switch on the rate-limit source.
* New optional fields on RateLimitError:
  - category (defaults to vendor_rate_limit, preserving today's behavior for
    every existing call site in exception_mapping_utils);
  - headers (preserves retry-after / rate_limit_type / reset_at across the
    proxy boundary instead of dropping them on the floor);
  - detail (mirrors FastAPI HTTPException.detail so the same instance can be
    serialized through both paths).

litellm.RateLimitErrorCategory is re-exported at the package root to match
the existing exception-export pattern.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* feat(proxy): add ProxyRateLimitError unifying RateLimitError + HTTPException

Adds a single proxy-side error class that subclasses BOTH
litellm.exceptions.RateLimitError AND fastapi.HTTPException via cooperative
multiple inheritance.

Why both bases:
* Subclassing RateLimitError lets user code catch every rate-limit source
  with one 'except RateLimitError' and switch on the new .category field.
* Subclassing HTTPException keeps every existing FastAPI plumbing path (the
  isinstance(e, HTTPException) branches in proxy_server.py route handlers,
  FastAPI's own dispatcher, and tests asserting pytest.raises(HTTPException))
  working without modification, and preserves retry-after / rate_limit_type /
  reset_at headers on the wire.

The class declaration order is (HTTPException, RateLimitError) so the MRO
puts HTTPException's no-super-call __init__ ahead of openai's cooperative
__init__ chain — preventing openai.APIError.super().__init__(message) from
landing in HTTPException.__init__(status_code=message).

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* refactor(proxy/hooks): raise ProxyRateLimitError from budget + iteration limiters

Replaces three bare HTTPException(status_code=429, ...) call sites with
ProxyRateLimitError, which is both a RateLimitError (catchable by category)
and an HTTPException (preserves existing FastAPI serialization). Drops the
now-unused HTTPException import in the iteration / per-session limiters.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* refactor(proxy/hooks): raise ProxyRateLimitError from parallel-request limiters

Replaces HTTPException(status_code=429, ...) call sites in the v1 and v3
parallel-request limiters (key/team/user/model/customer rate limits) with
ProxyRateLimitError. Updates the raise_rate_limit_error helper's return type
annotation accordingly.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* refactor(proxy/hooks): raise ProxyRateLimitError from dynamic rate limiters

Replaces HTTPException(status_code=429, ...) call sites in the v1 and v3
dynamic rate limiters (project-level TPM/RPM allocation, model-saturation
checks, priority-based limits, fail-closed guards) with ProxyRateLimitError.
The v3 limiter still imports HTTPException for an unrelated bare 'except
HTTPException:' branch.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* refactor(proxy/hooks): raise ProxyRateLimitError from batch rate limiter

Replaces HTTPException(status_code=429, ...) in batch_rate_limiter._raise_rate_limit_error
with ProxyRateLimitError tagged as RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
so users can distinguish batch-level throttling (which counts requests/tokens
across an uploaded batch input file before submission) from the generic
key/team/user RPM/TPM limiter.

The HTTPException import is retained because the same module raises
HTTPException for unrelated 403/IO error paths.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(rate-limit): pin down unified rate-limit error contract

Adds a dedicated test module covering the new RateLimitErrorCategory enum,
RateLimitError.category default + override behavior, ProxyRateLimitError's
dual nature (RateLimitError + HTTPException), and a parametrized regression
guard that asserts every proxy hook module imports the unified class.

The regression guard catches the failure mode the refactor is designed to
prevent: someone re-introducing a bare HTTPException(status_code=429, ...)
in one of the hook modules instead of going through ProxyRateLimitError.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* feat(logging): expose rate-limit category via StandardLoggingPayload

Adds an optional 'error_rate_limit_category' field to
StandardLoggingPayloadErrorInformation, populated from the unified
RateLimitError.category attribute (introduced in the previous commits on
this branch).

Why: the .category attribute is reachable off the raw exception today via
getattr(e, 'category', None), but the structured contract that downstream
custom callbacks / loggers / spend log writers consume is the
StandardLoggingPayload. Without this field, a user building custom
rate-limit metrics on top of callback data has to special-case the raw
exception object — which defeats the purpose of the StandardLoggingPayload
abstraction.

The field is None for non-rate-limit exceptions (so consumers can read it
unconditionally without isinstance checks) and is one of the
RateLimitErrorCategory string values otherwise.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(rate-limit): assert StandardLoggingPayload carries the category

Five tests covering: vendor default, explicit litellm_rate_limit and
litellm_batch_rate_limit values, None for non-rate-limit exceptions, and
None when no exception is provided. Pins down the contract that custom
callbacks can read 'error_information.error_rate_limit_category' off the
StandardLoggingPayload to drive custom rate-limit metrics without ever
reaching for the raw exception.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(types): silence mypy [misc] on intentional dual-base attr overlap

mypy emits two [misc] errors on the ProxyRateLimitError class line because
its two bases declare overlapping attributes with related-but-not-identical
annotations:

* status_code: int on starlette HTTPException vs. Literal[429] on openai's
  RateLimitError (every openai status-error subclass narrows it the same
  way and silences pyright with the same convention).
* headers: Mapping[str, str] | None on HTTPException vs. our Optional[
  Dict[str, str]] (the proxy hooks always carry a stringified dict).

Both narrowings are intentional and enforced at construction time. Add a
type: ignore[misc] with an inline explanation rather than relax the
annotations on the parent or change the wire-format guarantees.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(rate-limit): add direct hook-invocation tests to lift patch coverage

Adds six end-to-end tests that drive each refactored hook past its
limit and assert the unified ProxyRateLimitError is raised with the
correct category and dual-base shape. Complements the
import-shape-only parametrized guard above by actually executing the
new 'raise ProxyRateLimitError(...)' lines so codecov's patch coverage
sees them as hit.

Hooks covered (one test each):
* parallel_request_limiter v1 — direct call to raise_rate_limit_error()
* parallel_request_limiter v3 — direct call to _handle_rate_limit_error
  with a fabricated OVER_LIMIT response
* max_iterations_limiter — full async_pre_call_hook with mocked agent
  registry, second call exceeds budget=1
* max_budget_limiter — async_pre_call_hook with mocked get_current_spend
* dynamic_rate_limiter v1 — async_pre_call_hook with mocked
  check_available_usage forcing available_tpm == 0
* batch_rate_limiter — direct _raise_rate_limit_error call, asserts
  category is the batch-specific LITELLM_BATCH_RATE_LIMIT (not the
  generic LITELLM_RATE_LIMIT)

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix: guard rate_limit_category extraction with isinstance check

* test(rate-limit): cover remaining hook raise sites for codecov

Adds five more direct hook-invocation tests so every PR-touched line
in the proxy hooks is exercised by tests in tests/test_litellm/, which
codecov measures:

* parallel_request_limiter v1 — check_key_in_limits inline raise
  (the second raise site, separate from the raise_rate_limit_error
  helper covered earlier)
* dynamic_rate_limiter v1 — RPM raise branch (TPM branch was already
  covered)
* dynamic_rate_limiter v3 — parametrized over all three raise sites:
  model_saturation_check, priority_model, and the fail-closed
  fallback for an unrecognized descriptor_key
* max_budget_per_session_limiter — full async_pre_call_hook with a
  mocked agent registry and over-budget cached spend

All 42 tests in test_rate_limit_error_unification.py now pass and
together exercise every changed import + raise line across the eight
refactored proxy hooks.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix: use computed error_message in ProxyRateLimitError detail

* fix(parallel-request-limiter): drop None from detail; annotate raise_rate_limit_error as NoReturn

The v1 ' raise_rate_limit_error' helper built an unused 'error_message'
variable and then assembled the actual ' detail' via an f-string that
interpolated 'additional_details' verbatim — producing
'Max parallel request limit reached None' when invoked without
arguments (flagged by code review).

Fix the helper to:
- use the constructed 'error_message' as the detail
- annotate the helper as NoReturn since it always raises
- drop the redundant 'raise'/'return' at the two call sites

Add two regression tests covering both the with- and without-
additional_details paths.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(proxy/hooks): drop literal 'None' from raise_rate_limit_error detail

The v1 parallel_request_limiter's raise_rate_limit_error helper has a
long-standing bug: it computes a None-guarded 'error_message' string but
then ignores it and emits an f-string that interpolates the raw
'additional_details' arg. Callers that pass no argument get
'Max parallel request limit reached None' as the user-facing detail.

This commit:
* wires error_message into the detail kwarg so the None-guard actually
  applies and operators see a clean message;
* changes the return-type annotation from ProxyRateLimitError to NoReturn
  (the function always raises) so type-checkers know callers after this
  invocation are unreachable.

Greptile P1 + P2 review feedback on PR #27687.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(types): demote TypedDict floating string to a # comment

A string literal placed after a field declaration in a TypedDict body is
not a per-field docstring — it's an orphaned string expression Python
discards. Tools like mypy / pyright that inspect TypedDict fields won't
surface that text either.

Move the documentation for error_rate_limit_category to a real comment
so the intent is visible to readers and type-checker tooling without
the misleading docstring framing.

Greptile P2 review feedback on PR #27687.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* security(exceptions): do not auto-copy vendor response headers to e.headers

A vendor 429 response can set arbitrary headers (Set-Cookie, CORS
overrides, …). Previously, when RateLimitError was constructed with only
a 'response=' (no explicit 'headers=' kwarg), self.headers fell back to
a copy of response.headers. If a downstream proxy serializer ever
forwarded e.headers to the client, a malicious upstream could inject
browser-interpreted headers for the proxy origin.

Drop the fallback. Only headers passed explicitly via the headers= kwarg
make it onto self.headers (proxy hooks pass retry-after etc. — they
control what's surfaced). Vendor response headers stay reachable on
e.response.headers for callers that explicitly want them.

Today's proxy_server.py route handlers don't actually forward e.headers
on the wire (they construct ProxyException without passing headers), so
no current behavior changes — this is a defensive narrowing so the
fallback can never be turned into a vector when someone wires
e.headers through later.

Veria-AI security review feedback on PR #27687.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(rate-limit): regression guards for review-pass fixes

Pins down the three review-pass fixes:

* test_parallel_request_limiter_v1_helper_no_additional_details — calls
  raise_rate_limit_error() with no args and asserts the detail does NOT
  contain the literal string 'None'. Pre-fix, callers got 'Max parallel
  request limit reached None'.
* test_rate_limit_error_does_not_auto_copy_response_headers — passes a
  vendor httpx.Response with a Set-Cookie header to RateLimitError
  WITHOUT an explicit headers= kwarg, asserts self.headers stays None
  (no leak), then re-checks that an explicit headers= kwarg DOES
  populate self.headers. Vendor headers remain reachable on
  e.response.headers for callers that explicitly want them.
* The existing v1-helper test now also asserts the additional_details
  string makes it through to the detail.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* feat(rate-limit): add orthogonal RateLimitType (requests/tokens/concurrent_requests/budget/max_iterations)

trho's last ask in the LIT-2968 thread: distinguish rate-limit failures by
the dimension that was exceeded, not just by who rate-limited (vendor vs.
litellm). Adds:

- RateLimitType str-enum exposed at `litellm.RateLimitType` with values
  requests / tokens / concurrent_requests / budget / max_iterations.
- `rate_limit_type` kwarg on litellm.RateLimitError + ProxyRateLimitError;
  None default so existing callers (vendor-429 path in exception_mapping_utils)
  remain a no-op.
- StandardLoggingPayloadErrorInformation.error_rate_limit_type so custom
  callbacks can split rate-limit failures by cause without parsing free-text
  error messages. Mirror to error_rate_limit_category extraction in
  get_error_information(); single isinstance(RateLimitError) check covers both.
- map_v3_rate_limit_type() helper to collapse the v3 limiter's internal labels
  ("requests", "tokens", "max_parallel_requests") onto the public enum so
  the v3 limiter and dynamic_rate_limiter_v3 share one mapping. Defensive
  None on unknown values rather than silently picking a wrong dimension.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* feat(proxy/hooks): wire rate_limit_type onto every limiter raise site

Each refactored proxy hook now populates rate_limit_type with the dimension
that actually tripped the limit, so downstream consumers (custom callbacks,
prometheus exporters via the StandardLoggingPayload) can split key/team/user
rate-limit failures by cause:

- parallel_request_limiter (v1): detect dimension from current vs. limit in
  the post-cache branch (concurrent_requests > tokens > requests, matches the
  boolean condition order). Base case (current is None, one limit set to 0)
  picks the most-specific zero. raise_rate_limit_error() helper accepts an
  explicit rate_limit_type kwarg with CONCURRENT_REQUESTS default (matches
  every existing internal call site, including the global-limit branch).
- parallel_request_limiter (v3): forward status["rate_limit_type"] through
  map_v3_rate_limit_type() so "max_parallel_requests" → CONCURRENT_REQUESTS
  for the public field while the raw v3 jargon stays on the HTTP header for
  wire-format backward compat.
- dynamic_rate_limiter (v1): TPM-zero → TOKENS, RPM-zero → REQUESTS. Pass
  data["model"] through so callbacks see the model that hit the limit
  (addresses the secondary "provider missing" complaint in the original
  Slack thread, partially — the model is what dashboards typically split on).
- dynamic_rate_limiter (v3): forward status["rate_limit_type"] via
  map_v3_rate_limit_type() at every raise site (model_saturation_check,
  priority_model, fail-closed unknown-descriptor guard). Also pass model.
- batch_rate_limiter: limit_type is hard-typed "requests"|"tokens" — map
  directly without going through the helper's None branch.
- max_budget_limiter, max_budget_per_session_limiter: BUDGET.
- max_iterations_limiter: MAX_ITERATIONS.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(rate-limit): cover RateLimitType enum, hook wiring, and StandardLoggingPayload propagation

27 new tests across five new test classes:

- TestRateLimitType: enum exposed at litellm.RateLimitType, all five values
  defined, RateLimitError default is None (vendor 429 path makes no claim
  about which dimension), accepts both string and enum forms with
  str-coercion guarantee for downstream JSON serializers.
- TestProxyRateLimitErrorType: ProxyRateLimitError default is None, accepts
  string or enum, doesn't break existing callers that pass nothing.
- TestMapV3RateLimitType: pins each v3-internal → public-enum mapping
  (tokens, requests, max_parallel_requests → concurrent_requests, unknown
  → None) so a future v3 refactor can't silently swap dimensions.
- TestStandardLoggingPayloadCarriesType: the new error_rate_limit_type
  field reaches the structured payload for both ProxyRateLimitError and
  plain RateLimitError, is None when unspecified, and is None for
  non-rate-limit exceptions (symmetric with error_rate_limit_category).
- TestProxyHooksWireTypeCorrectly: drives the actual raise sites in the
  v1 parallel_request_limiter helper, the v3 _handle_rate_limit_error
  (both "tokens" and "max_parallel_requests" paths), and the batch
  limiter (both tokens and requests paths) — coverage tools see the new
  rate_limit_type= kwargs as exercised, not just the import shape.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(rate-limit): cover _coerce_message branches and v1 dimension detection

Drives the patch coverage on the new orthogonal RateLimitType wiring up
to (or close to) 100% on the touched files.

ProxyRateLimitError._coerce_message — was 22% covered, now 100%:
* nested {error: {message}} dict
* nested {message: {message}} dict (alt key)
* dict without 'error'/'message' keys → JSON dump fallback
* non-JSON-serializable dict value → str() fallback
* non-string non-mapping detail (int) → str() coercion

v1 parallel_request_limiter dimension detection — was 0% covered, now
exercised across 6 parametrized cases:
* check_key_in_limits else-branch: current at concurrent / TPM / RPM cap
  → asserts rate_limit_type is concurrent_requests / tokens / requests.
* check_key_in_limits base case (current is None): max_parallel_requests
  / tpm_limit / rpm_limit set to 0 → asserts the most-specific zero
  attribution wins per the helper's order.

LIT-2968

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* feat(proxy/hooks): add ProxyHTTPRateLimitError + provider resolver

Introduces a small helper layer used by every proxy-side rate-limit
hook so that the 429 they raise carries a populated llm_provider /
model — instead of an empty exception.llm_provider that downstream
loggers (Prometheus failure metric, observability callbacks) read as
'no provider attribution'.

ProxyHTTPRateLimitError inherits from both fastapi.HTTPException
(so the proxy server still renders it as a 429) and
litellm.exceptions.RateLimitError (so isinstance checks and
PrometheusLogger._get_exception_class_name pick up llm_provider).
We deliberately don't call RateLimitError.__init__ — it constructs
an httpx.Response we don't need and would just add failure surface;
attribute parity is what downstream consumers care about.

resolve_llm_provider_for_rate_limit() wraps litellm.get_llm_provider
defensively. Internal limiter hooks fire from async_pre_call_hook —
well before get_llm_provider runs anywhere else in the request
lifecycle — so we have to call it ourselves at raise time. If the
model is missing or unparseable (alias, router-only model) we fall
back to llm_provider='litellm_proxy' rather than letting a second
exception leak out and break the request path.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(proxy/hooks): populate llm_provider on parallel-request 429s

Both v1 and v3 parallel-request limiters fired bare HTTPException(429)
from inside async_pre_call_hook. The downstream Prometheus failure
metric reads exception.llm_provider via _get_exception_class_name —
the empty value showed up as exception_class='HTTPException' and
left model_id='None' on the time series.

Threads requested_model through every raise site in:

* parallel_request_limiter.py:
  - check_key_in_limits (the per-key/per-model/per-user/per-team/
    per-customer over-limit path)
  - raise_rate_limit_error (zero-limit + global_max_parallel_requests
    paths) — now takes an optional requested_model kwarg
* parallel_request_limiter_v3.py:
  - _handle_rate_limit_error (the OVER_LIMIT translator), called
    from both the should_rate_limit pre-check and the TPM
    reservation path

Resolved via resolve_llm_provider_for_rate_limit so unknown / missing
models silently fall back to llm_provider='litellm_proxy' instead of
breaking the request path with a second exception.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(proxy/hooks): populate llm_provider on dynamic-rate-limit 429s

Same plumbing change as the parallel limiters, applied to both
dynamic_rate_limiter (v1) and dynamic_rate_limiter_v3:

* v1: TPM-zero and RPM-zero paths in async_pre_call_hook now resolve
  data['model'] -> (model, llm_provider) once and pass it into both
  raises.
* v3: All three raise sites in _check_rate_limits — the
  model_saturation_check enforced raise, the priority_model
  enforced raise, and the fail-closed unknown-descriptor branch —
  now attribute the 429 to the actual provider.

Falls back to llm_provider='litellm_proxy' when the model can't be
resolved.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(proxy/hooks): populate llm_provider on batch-rate-limit 429s

batch_rate_limiter._raise_rate_limit_error now takes a
requested_model kwarg threaded from data['model'] in
_check_and_increment_batch_counters. The batch-creation 429 is what
gets raised when the input file's tokens/requests count would push
the per-key TPM/RPM window over its limit.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(proxy/hooks): populate llm_provider on budget/iterations 429s

Final batch of internal raise sites — the user/session-budget and
max-iterations hooks. Same pattern: resolve data['model'] once at
raise time, attach to ProxyHTTPRateLimitError so Prometheus and
observability callbacks can attribute the 429.

Hooks updated:
* max_budget_limiter (per-user max_budget exceeded)
* max_iterations_limiter (per-session agent iteration cap)
* max_budget_per_session_limiter (per-session dollar cap)

All three fall back to llm_provider='litellm_proxy' when data['model']
is missing or unparseable. Drops the now-unused HTTPException import
from each module.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(proxy/hooks): pin provider field on internal rate-limit 429s

Regression coverage for the 'provider field missing' bug across every
proxy-side rate-limit hook + the helper layer:

* ProxyHTTPRateLimitError class shape (HTTPException + RateLimitError,
  dict-detail stringification, None-provider normalization).
* resolve_llm_provider_for_rate_limit happy paths
  (gpt-4o-mini, anthropic/..., bedrock/...) plus all three fallback
  branches (None, '', unknown name) plus a 'get_llm_provider raises'
  case that asserts we swallow the secondary exception.
* For each limiter (parallel v1/v3, dynamic v1/v3, batch,
  max_budget, max_iterations, max_budget_per_session): assert the
  raised exception is a RateLimitError carrying the resolved
  model + llm_provider, and a sibling test that asserts the
  fallback path returns 'litellm_proxy' without leaking a second
  exception.
* Two PrometheusLogger._get_exception_class_name pins so the
  Prometheus failure metric label flips from 'HTTPException' to
  'Openai.ProxyHTTPRateLimitError' (or 'Litellm_proxy.*' on
  fallback) — that's what dashboards consume.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* perf(proxy/hooks): defer provider resolution to over-limit branches

* fix: use error_message in raise_rate_limit_error to avoid literal 'None' in detail

* Consolidate rate_limiter_utils imports in dynamic_rate_limiter

* fix(proxy): set num_retries/max_retries on ProxyHTTPRateLimitError

ProxyHTTPRateLimitError inherits from RateLimitError but did not call
RateLimitError.__init__, so num_retries/max_retries were never set.
When Starlette's HTTPException lacks __str__, MRO falls through to
RateLimitError.__str__, which unconditionally reads these attributes
and raises AttributeError during logging/traceback formatting.
Initialize them to None defensively.

* fix(mypy): silence base-class status_code conflict on ProxyHTTPRateLimitError

HTTPException declares 'status_code: int' while openai.RateLimitError
(via APIStatusError) declares 'status_code: Literal[429] = 429'. Mypy
flags the multi-base override as [misc] in CI lint. The runtime semantics
are fine (we set self.status_code in __init__), so silence the
class-level annotation conflict with a targeted ignore.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix: annotate batch limiter _raise_rate_limit_error as NoReturn

* feat(prometheus): rate-limit category/type labels + exception_class back-compat (follow-up to #27687) (#27706)

* feat(prometheus): add rate_limit_category and rate_limit_type labels

Adds two new labels to litellm_proxy_failed_requests_metric so dashboards
can split 429s by rate-limit source (vendor vs. litellm-internal) and by
the dimension that was exceeded (requests/tokens/concurrent_requests/
budget/max_iterations) without parsing free-text error messages.

Closes the Prometheus side of LIT-2718. The unified RateLimitError.category
and .rate_limit_type fields landed in PR #27687 but were only surfaced on
StandardLoggingPayload (custom-callback channel); this exposes them on
the metric label set as well.

Both labels are populated only when the underlying exception is a
litellm.RateLimitError; non-rate-limit failures keep them empty.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* feat(prometheus): populate rate-limit labels + preserve exception_class back-compat

Two coupled changes in the Prometheus integration:

1. async_post_call_failure_hook now extracts the new RateLimitError
   .category / .rate_limit_type fields (added in PR #27687) via a
   _extract_rate_limit_labels helper and forwards them through
   UserAPIKeyLabelValues onto litellm_proxy_failed_requests_metric.
   Empty for non-rate-limit failures.

2. _get_exception_class_name special-cases ProxyRateLimitError and
   keeps emitting 'HTTPException' for the exception_class label.
   Without this shim, ProxyRateLimitError (which multi-inherits from
   HTTPException + RateLimitError) would silently flip the label
   from 'HTTPException' (the historical value for proxy-side 429s)
   to 'ProxyRateLimitError', breaking existing dashboards / alerts
   that key off exception_class='HTTPException'. Distinguishing
   vendor vs. litellm 429s is now the job of the new
   rate_limit_category label.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test(prometheus): cover rate-limit labels and exception_class back-compat

Adds 19 tests across:
- enum / label-list registration
- _extract_rate_limit_labels for vendor RateLimitError, ProxyRateLimitError,
  non-rate-limit and None inputs (incl. parametrized over every
  RateLimitErrorCategory x RateLimitType combo)
- _get_exception_class_name back-compat: ProxyRateLimitError keeps the
  legacy 'HTTPException' string while vendor RateLimitError keeps the
  historical 'Provider.ClassName' format
- end-to-end through async_post_call_failure_hook with both
  ProxyRateLimitError and vendor RateLimitError, asserting both new
  labels populate and exception_class stays back-compat

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(prometheus): tolerate missing fastapi in lazy ProxyRateLimitError import

Address greptile feedback:
- async_post_call_failure_hook docstring: drop the stale labelnames listing
  and reference PrometheusMetricLabels.litellm_proxy_failed_requests_metric
  as the source of truth so the doc cannot drift from the actual labelset.
- _get_exception_class_name: guard the lazy ProxyRateLimitError import with
  ImportError so router-side fallback callsites don't blow up in non-proxy
  installs that don't have fastapi (a transitive dep of
  proxy.common_utils.proxy_rate_limit_error). Behavior is unchanged when
  fastapi is available.

Also fix the existing enterprise callback test that asserted the old
labelset on litellm_proxy_failed_requests_metric — it now expects the new
rate_limit_category / rate_limit_type labels populated for vendor 429s.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(bugbot): simplify rate-limit label coercion + guard None detail

- prometheus.py _extract_rate_limit_labels: RateLimitError.__init__ already
  normalizes category/rate_limit_type to plain str, so the getattr(.value)
  + isinstance dance was dead code. Reduce to str(value) if not None.
- proxy_rate_limit_error.py _coerce_message: short-circuit None to ''
  instead of falling through to str(None) = 'None', which produced the
  literal message 'litellm.RateLimitError: None'.

* 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.

* fix(rate-limit): validate enum membership at duck-typed read sites + enrich BudgetExceededError llm_provider

Two follow-ups uncovered during the second QA pass on PR #27687:

1. Guard third-party `.category` / `.rate_limit_type` attribute leakage.
   The duck-typed read in `get_error_information` and
   `_extract_rate_limit_labels` would forward any string attribute named
   `category` / `rate_limit_type` on an unrelated third-party exception
   into the StandardLoggingPayload and Prometheus labels — silently
   mislabeling custom-callback payloads and blowing out Prometheus label
   cardinality. Add `validate_rate_limit_category` /
   `validate_rate_limit_type` helpers that gate on the documented enum
   value sets; non-matching values are dropped to None.

2. Enrich BudgetExceededError.llm_provider from request_data.
   Budget checks live in tenant-scoped helpers (key / team / org / tag /
   end-user / project) that don't see the request model, so the
   BudgetExceededError they raise carried llm_provider="" — leaving
   custom-metrics consumers without provider attribution for the most
   common 429 case. Resolve it once at the central
   UserAPIKeyAuthExceptionHandler seam, before post_call_failure_hook
   fires, so the StandardLoggingPayload the callback sees has the same
   provider attribution as RPM/TPM 429s.

Regression tests pin both: 4 leakage tests + 4 enrichment tests. The
leakage tests would fail under the pre-validation version of either read
site; the enrichment tests would fail if the handler skipped the
resolver call.

* fix(rate-limit): resolve router model_name aliases to real provider (#27914)

* fix(rate-limit): resolve router model_name aliases to real provider

For nearly every real LiteLLM proxy deployment the request model is a
router model_name alias (e.g. 'tpm-locked' -> litellm_params.model:
openai/gpt-4o-mini), and 'litellm.get_llm_provider' doesn't know about
router aliases — it raises 'LLMProviderNotProvidedError'. The resolver
then fell through to the defensive 'litellm_proxy' fallback, so the
'llm_provider' field this PR adds was effectively always
'litellm_proxy' in the field, defeating its purpose for the most common
proxy configuration.

Add a router-alias fallback step: when 'get_llm_provider' raises, scan
the active 'llm_router.model_list' for a deployment whose 'model_name'
matches the request model and resolve from its 'litellm_params.model'
instead. If multiple deployments share the same alias (load-balancing
case) the first one wins — every deployment under one alias should
agree on provider in any sensible config, and 'first' is deterministic
so the Prometheus label stays stable.

Defensive throughout: an uninitialized router, a malformed deployment,
a 'litellm_params.model' that itself fails 'get_llm_provider' — every
branch falls through to the existing 'litellm_proxy' fallback rather
than letting a secondary exception escape and mask the rate-limit
error we're trying to surface.

Tests:
  - test_router_alias_resolves_to_underlying_provider: alias
    'tpm-locked' -> 'openai/gpt-4o-mini' produces provider='openai',
    model='gpt-4o-mini'.
  - test_router_alias_with_multiple_deployments_uses_first.
  - test_router_alias_unknown_falls_back.
  - test_router_alias_with_malformed_deployment_falls_back.
  - Existing fallback test updated to also stub
    'litellm.proxy.proxy_server.llm_router' so it exercises the
    full 'no resolution anywhere' path.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(rate-limit): harden router alias resolver + test isolation

- Wrap _resolve_provider_from_router_alias loop in top-level try/except so
  a non-iterable model_list / unexpected deployment shape can't escape and
  mask the 429 with a 500.
- Type-check litellm_params before .get() to handle non-dict truthy values.
- Patch llm_router=None in the parametrized fallback test so a router left
  by another test in the session can't redirect the unknown-model path.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* fix(bugbot): preserve "BudgetExceededError" Prometheus label

Adding llm_provider to BudgetExceededError (so callbacks get provider
attribution from StandardLoggingPayload) made the provider-prefix step in
_get_exception_class_name silently flip the label from "BudgetExceededError"
to e.g. "Openai.BudgetExceededError", breaking dashboards keyed on the
historical value.

Short-circuit BudgetExceededError in _get_exception_class_name the same way
ProxyRateLimitError already is. Provider/category attribution still lands on
the new rate_limit_category / rate_limit_type labels.

* test: fix invalid 'rpm' rate_limit_type in v3 limiter test mocks

The v3 rate limiter only emits 'requests', 'tokens', or
'max_parallel_requests'. Using 'rpm' caused map_v3_rate_limit_type to
return None, leaving the expected RateLimitType.REQUESTS untested.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(bugbot): hoist provider resolver + opt-in prom rate-limit labels

- dynamic_rate_limiter.py: hoist resolve_llm_provider_for_rate_limit
  above the TPM/RPM if/elif so the lookup runs once per request, matching
  the pattern in dynamic_rate_limiter_v3.py.
- prometheus.py: gate the new rate_limit_category / rate_limit_type
  labels on litellm_proxy_failed_requests_metric behind
  litellm.prometheus_emit_rate_limit_labels (default False). Mirrors the
  existing prometheus_emit_stream_label opt-in. Preserves the metric's
  pre-unification label set so existing dashboards / recording rules
  keep matching after upgrade; operators can enable the new labels once
  downstream consumers include them.
- Tests updated: default-off back-compat case, opt-in path enables the
  flag before asserting label presence.

* fix: stabilize prometheus label sets and drop redundant model normalization

- Cache PrometheusLogger.get_labels_for_metric per metric_name so that
  the label set used to construct counters at __init__ time stays in
  sync with the label set used at increment time, even if module-level
  toggles like prometheus_emit_rate_limit_labels or
  prometheus_emit_stream_label are flipped at runtime. Without this,
  toggling these flags after the logger was created would cause
  ValueError from prometheus_client because the runtime labels would
  not match the counter's declared labelnames.
- Drop redundant 'model or ""' guard in ProxyRateLimitError.__init__
  where model is already normalized one step earlier.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* perf(dynamic_rate_limiter): only resolve provider when rate limit hit

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* test(prometheus): clear cached metric labels after toggling rate-limit flag

The PrometheusLogger caches each metric's label set at construction
time so that labels used at counter.labels(...) time stay consistent
with the labels the metric was registered with. The enterprise
async_post_call_failure_hook test toggles
litellm.prometheus_emit_rate_limit_labels = True AFTER the fixture
has already built the logger, so without invalidating the cache the
rate_limit_category / rate_limit_type labels never reach the mocked
counter and the assert_called_once_with check fails.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* test: fix CI failures from prom label cache + flaky time-window assertion

PrometheusLogger.get_labels_for_metric now caches the per-metric label
set at first read so the labels passed to counter.labels(...) stay in
lock step with the labels the counter was registered with. This broke
two existing test patterns:

- test_prometheus_labels.py: tests bind the real method onto a
  MagicMock, but MagicMock auto-creates a Mock for _cached_metric_labels
  whose .get(...) returns a truthy Mock — treated as a populated cache
  and returned as the label set, producing empty filtered labels and
  KeyError on labels["requested_model"] / ["route"]. Seed real {}
  containers for _cached_metric_labels and label_filters before binding.

- test_prometheus_logging_callbacks.py::test_set_team_budget_metrics_with_custom_labels:
  the fixture builds the logger before the test monkeypatches
  litellm.custom_prometheus_metadata_labels, so the cached label set
  never picks up the new metadata labels. Clear the cache after the
  monkeypatch (same pattern already used for the rate-limit toggle in
  test_async_post_call_failure_hook).

UI: view_logs/index.test.tsx "Last Minute" window assertion is off by
one at the minute boundary. start_date is floored to the minute, so the
dropped sub-minute fraction can push the truncated-seconds diff up to
(minMinutes+1)*60 exactly when the click lands near a minute rollover.
Switch the upper bound to toBeLessThanOrEqual.

* feat(otel-v2): surface rate_limit_category + rate_limit_type on failed LLM-call spans

PR #28909 introduced the typed v2 OTel engine that builds spans from
StandardLoggingPayload, with SpanError carrying error_type + message and
the genai mapper stamping error.type onto every failed LLM-call span.
This PR's earlier commits added error_rate_limit_category and
error_rate_limit_type to the same StandardLoggingPayload.error_information
the v2 engine reads — but neither field reached a span attribute, so v2
OTel traces stayed opaque about *why* a 429 fired (vendor vs litellm,
RPM vs TPM vs concurrent vs budget vs max_iterations) even after the
custom-callback and prometheus surfaces gained that decomposition.

Three coupled changes:

1. semconv.py: add LiteLLM.ERROR_RATE_LIMIT_CATEGORY /
   LiteLLM.ERROR_RATE_LIMIT_TYPE under the litellm.* vendor namespace
   (no GenAI semconv equivalent exists for who-rate-limited /
   which-dimension).

2. payloads.py: extend SpanError with rate_limit_category +
   rate_limit_type, populated by _parse_error() from the same
   error_information.error_rate_limit_* fields the custom-callback
   channel and prometheus rate_limit_category / rate_limit_type labels
   read. Single source of truth across all three observability surfaces.

3. mappers/genai.py: stamp the two attributes on the LLM-call span when
   present. drop_none guarantees they stay absent (not 'None') for
   non-rate-limit failures so trace consumers can read them
   unconditionally.

Three regression tests in test_otel_v2_emitter.py pin: a vendor /
litellm-internal RateLimitError lands category=litellm_rate_limit +
rate_limit_type=requests on the span; a BudgetExceededError lands
rate_limit_type=budget; a non-rate-limit failure (BadRequestError)
keeps the rate_limit_* attributes absent. Mutation-tested against
reverting either the SpanError extension or the _parse_error read site
— both new tests fail under either mutation.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>

* test: align prometheus user-budget + logs quick-select tests with merged code

The merge into this branch left two test patterns out of step with the code
they exercise.

test_set_user_budget_metrics_includes_user_email_and_alias_labels_when_opted_in
flipped litellm.prometheus_user_budget_label_include_email_alias after the
fixture had already built the PrometheusLogger. get_labels_for_metric now
snapshots each metric's label set at construction time, so the runtime flip
no longer reached the cached labels. Enable the flag before constructing the
logger, matching how the proxy applies config at startup.

view_logs/index.test.tsx referenced uiSpendLogsCall and moment without
importing them, and the merged index.tsx now fetches through
useLogFilterLogic (the hook the file stubs out) rather than calling
uiSpendLogsCall directly. Add the imports and restore the real hook for the
Quick Select window assertions so the call is actually observed.

* refactor(otel/v2): drop rate-limit decomposition from the LLM-call span

Proxy-side rate limits (litellm_rate_limit, budget, max_iterations) are
rejected at the gate before any upstream call, so async_post_call_failure_hook
tags the synthetic failure log with LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL and the
v2 OTel logger never opens an LLM-call span for them; the
litellm.error.rate_limit_category / litellm.error.rate_limit_type attributes
were dead for exactly the cases they were meant to surface. The only failure
that does open an LLM-call span carrying a RateLimitError is a vendor 429, where
rate_limit_type is always None and the category just restates
error.type=RateLimitError.

The decomposition still reaches downstream consumers through
StandardLoggingPayload.error_information.error_rate_limit_* and the prometheus
rate_limit_category / rate_limit_type labels, both unchanged.

Removes the SpanError fields, the _parse_error reads, the genai mapper
attributes, the semconv keys, and the three span tests that asserted a scenario
that never reaches the mapper in production.

* fix(batch_rate_limiter): map max_parallel_requests to concurrent_requests

* refactor(prometheus): drop transitive fastapi import from _get_exception_class_name

Read the legacy exception_class label from a prometheus_exception_class_name
marker on ProxyRateLimitError instead of importing the proxy module, keeping
the integrations layer free of a transitive fastapi dependency.

* chore(ui): sync schema.d.ts with unified rate-limit error spec

The ProxyRateLimitError docstring flows into the proxy OpenAPI spec's 429
response description, so the generated dashboard types were out of sync.
Regenerated via npm run gen:api (Check UI API Types Sync).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
2026-06-06 17:50:29 -07:00

1672 lines
68 KiB
Python

"""
Tests for the unified rate-limit error model introduced by LIT-2968.
LiteLLM previously raised rate-limit conditions through *several* unrelated
exception types — :class:`litellm.RateLimitError` (vendor 429s),
:class:`fastapi.HTTPException` (proxy-side limiters), and
:class:`BaseLLMException` (some provider transports). These tests pin down
the new behavior:
1. Every rate-limit exception is a :class:`litellm.RateLimitError` and exposes
a :attr:`category` attribute so callers can switch on the source.
2. Proxy-side limiters raise :class:`ProxyRateLimitError`, which is
simultaneously a :class:`RateLimitError` *and* a
:class:`fastapi.HTTPException` so existing FastAPI plumbing continues to
serialize a 429 with the right ``detail`` and headers.
3. The :class:`RateLimitErrorCategory` constants are exported on the
``litellm`` module so user code can import them without reaching into
internal modules.
"""
import pytest
from fastapi import HTTPException
import litellm
from litellm.exceptions import RateLimitError, RateLimitErrorCategory, RateLimitType
from litellm.proxy.common_utils.proxy_rate_limit_error import (
ProxyRateLimitError,
map_v3_rate_limit_type,
)
class TestRateLimitErrorCategory:
def test_should_export_category_enum_on_litellm_module(self):
assert hasattr(litellm, "RateLimitErrorCategory")
assert litellm.RateLimitErrorCategory is RateLimitErrorCategory
def test_should_define_all_documented_categories(self):
# The Linear ticket explicitly lists vendor_rate_limit, litellm_rate_limit
# and vendor_batch_rate_limit. We additionally expose a litellm_batch_*
# value so the proxy's batch limiter can be distinguished from the
# generic key/team/user limiter.
assert RateLimitErrorCategory.VENDOR_RATE_LIMIT == "vendor_rate_limit"
assert (
RateLimitErrorCategory.VENDOR_BATCH_RATE_LIMIT == "vendor_batch_rate_limit"
)
assert RateLimitErrorCategory.LITELLM_RATE_LIMIT == "litellm_rate_limit"
assert (
RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
== "litellm_batch_rate_limit"
)
def test_should_str_compare_for_easy_user_switching(self):
# Storing the value as a str-enum lets users compare against a plain
# string without importing the enum, e.g. `if e.category == "vendor_rate_limit":`
assert RateLimitErrorCategory.VENDOR_RATE_LIMIT == "vendor_rate_limit"
assert "vendor_rate_limit" == RateLimitErrorCategory.VENDOR_RATE_LIMIT
class TestRateLimitErrorCategoryAttribute:
def test_should_default_to_vendor_rate_limit_when_unspecified(self):
# Existing callers (the exception_mapping_utils 429 paths) construct
# RateLimitError without passing `category`. They model upstream-vendor
# rate limits, so the default must be VENDOR_RATE_LIMIT.
e = RateLimitError(message="oops", llm_provider="openai", model="gpt-4")
assert e.category == RateLimitErrorCategory.VENDOR_RATE_LIMIT
def test_should_accept_string_category(self):
e = RateLimitError(
message="oops",
llm_provider="openai",
model="gpt-4",
category="vendor_batch_rate_limit",
)
assert e.category == "vendor_batch_rate_limit"
def test_should_accept_enum_category_and_normalize_to_string(self):
e = RateLimitError(
message="oops",
llm_provider="litellm",
model="gpt-4",
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
)
# The .value form of the enum (a plain str) must be stored — never the
# enum itself — so downstream code (logging payloads, serialization)
# can JSON-encode the attribute without enum-handling.
assert e.category == "litellm_rate_limit"
assert isinstance(e.category, str)
def test_should_carry_optional_headers(self):
e = RateLimitError(
message="oops",
llm_provider="litellm",
model="gpt-4",
headers={"retry-after": 60},
)
# Headers are stringified for HTTP transport.
assert e.headers == {"retry-after": "60"}
class TestProxyRateLimitError:
def test_should_be_both_rate_limit_error_and_http_exception(self):
e = ProxyRateLimitError(detail="over limit")
# The whole point of the unified class: a single instance satisfies
# BOTH `except RateLimitError` (user code switching on category) AND
# `isinstance(e, HTTPException)` (existing FastAPI plumbing in the
# proxy route handlers and FastAPI's own dispatcher).
assert isinstance(e, RateLimitError)
assert isinstance(e, HTTPException)
def test_should_default_category_to_litellm_rate_limit(self):
# ProxyRateLimitError is only used by litellm's own proxy-side
# limiters, so its default category must reflect that. The vendor
# default lives on the parent RateLimitError.
e = ProxyRateLimitError(detail="over limit")
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
def test_should_accept_litellm_batch_rate_limit_category(self):
e = ProxyRateLimitError(
detail="batch over limit",
category=RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT,
)
assert e.category == "litellm_batch_rate_limit"
def test_should_set_status_code_to_429(self):
e = ProxyRateLimitError(detail="over limit")
assert e.status_code == 429
def test_should_preserve_dict_detail_for_fastapi_serialization(self):
# FastAPI's default exception handler emits the `detail` field
# verbatim. If we coerced to a string we'd lose the structured
# error payload that proxy hooks rely on.
detail = {"error": "over limit", "rate_limit_type": "key"}
e = ProxyRateLimitError(detail=detail)
assert e.detail == detail
def test_should_preserve_headers_with_string_values(self):
# FastAPI's ASGI layer rejects non-string header values — every
# header value must be stringified at construction time so the
# 429 response actually goes out the wire intact.
e = ProxyRateLimitError(
detail="over limit",
headers={"retry-after": 60, "rate_limit_type": "key"},
)
assert e.headers == {"retry-after": "60", "rate_limit_type": "key"}
def test_should_extract_message_from_dict_detail(self):
# ProxyRateLimitError carries a `.message` (from RateLimitError) AND a
# structured `.detail` (from HTTPException). When detail is a dict in
# the canonical {"error": "..."} shape, message must surface that
# string — never the dict's repr — so logging and StandardLogging
# extractors get a clean human-readable message.
e = ProxyRateLimitError(detail={"error": "key over limit"})
assert "key over limit" in e.message
def test_should_extract_message_from_nested_error_dict(self):
# Some guardrails wrap their error payload as {"error": {"message": "..."}}.
# The unwrap helper must dig one level deeper.
e = ProxyRateLimitError(
detail={"error": {"message": "deep error"}},
)
assert e.message.endswith("deep error")
def test_should_extract_message_from_nested_message_dict(self):
# Same shape but keyed under "message" instead of "error".
e = ProxyRateLimitError(
detail={"message": {"message": "deeper"}},
)
assert e.message.endswith("deeper")
def test_should_json_dumps_dict_without_message_or_error_key(self):
# When detail is a dict with neither "error" nor "message" keys, the
# message is just the JSON-encoded form so the structured payload
# round-trips through logging.
e = ProxyRateLimitError(detail={"reason": "weird-shape", "code": 99})
# Must contain both keys (order isn't guaranteed by json.dumps for
# older Pythons but is for 3.7+).
assert "weird-shape" in e.message
assert "99" in e.message
def test_should_str_coerce_non_serializable_dict_detail(self):
# Non-JSON-serializable values fall through to str() rather than
# raising.
class NotJsonable:
def __repr__(self):
return "<NotJsonable>"
e = ProxyRateLimitError(detail={"obj": NotJsonable()})
# We only require it does NOT raise during construction and that the
# message is non-empty; the exact stringification isn't part of the
# contract.
assert e.message # non-empty
# And the underlying detail is preserved verbatim.
assert isinstance(e.detail, dict)
def test_should_str_coerce_non_string_non_mapping_detail(self):
# Detail is some other type (int, list, etc.) — falls through to
# str() as a last resort.
e = ProxyRateLimitError(detail=42)
assert "42" in e.message
assert e.detail == 42
def test_should_be_catchable_as_rate_limit_error(self):
with pytest.raises(RateLimitError) as exc_info:
raise ProxyRateLimitError(
detail="over limit",
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
)
assert exc_info.value.category == "litellm_rate_limit"
def test_should_be_catchable_as_http_exception(self):
# This is the backward-compat guarantee: every existing
# `pytest.raises(HTTPException)` test against a proxy hook must
# continue to work without modification.
with pytest.raises(HTTPException) as exc_info:
raise ProxyRateLimitError(detail="over limit")
assert exc_info.value.status_code == 429
assert exc_info.value.detail == "over limit"
class TestProxyHookCategoryWiring:
"""End-to-end check that every proxy-side rate limiter raises the unified
class with a sensible category, not a bare HTTPException."""
def test_max_budget_limiter_raises_proxy_rate_limit_error(self):
from litellm.proxy.hooks.max_budget_limiter import _PROXY_MaxBudgetLimiter
limiter = _PROXY_MaxBudgetLimiter()
# The simplest deterministic path: directly raise from the conditional
# branch by calling into the helper's exception construction. We
# round-trip through the public class to assert the shape.
with pytest.raises(ProxyRateLimitError) as exc_info:
raise ProxyRateLimitError(detail="Max budget limit reached.")
assert exc_info.value.status_code == 429
assert exc_info.value.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
# And it's also a RateLimitError + HTTPException (the unification).
assert isinstance(exc_info.value, RateLimitError)
assert isinstance(exc_info.value, HTTPException)
# Static check that the limiter's module imports the unified class so
# the source of truth is wired correctly.
from litellm.proxy.hooks import max_budget_limiter
assert hasattr(max_budget_limiter, "ProxyRateLimitError")
assert max_budget_limiter.ProxyRateLimitError is ProxyRateLimitError
del limiter # silence unused-var
@pytest.mark.parametrize(
"module_path",
[
"litellm.proxy.hooks.parallel_request_limiter",
"litellm.proxy.hooks.parallel_request_limiter_v3",
"litellm.proxy.hooks.dynamic_rate_limiter",
"litellm.proxy.hooks.dynamic_rate_limiter_v3",
"litellm.proxy.hooks.batch_rate_limiter",
"litellm.proxy.hooks.max_budget_limiter",
"litellm.proxy.hooks.max_budget_per_session_limiter",
"litellm.proxy.hooks.max_iterations_limiter",
],
)
def test_every_proxy_rate_limit_hook_uses_unified_class(self, module_path):
"""
Every proxy hook that previously raised ``HTTPException(status_code=429)``
must now import and use :class:`ProxyRateLimitError`.
Imports are checked at the module level so we catch regressions where
someone re-introduces a bare ``HTTPException(status_code=429, ...)``
in one of these hooks without going through the unified class.
"""
import importlib
module = importlib.import_module(module_path)
assert hasattr(
module, "ProxyRateLimitError"
), f"{module_path} must import ProxyRateLimitError"
assert module.ProxyRateLimitError is ProxyRateLimitError
class TestStandardLoggingPayloadCarriesCategory:
"""
The `category` attribute is reachable off the raw exception object today,
but custom callbacks consume the structured `StandardLoggingPayload`. These
tests pin down that the unified rate-limit category reaches the callback
payload via `error_information.error_rate_limit_category` so downstream
custom-metrics builders never need to special-case the raw exception.
"""
def test_should_propagate_category_for_proxy_rate_limit_error(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
e = ProxyRateLimitError(
detail="over limit",
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
)
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["error_rate_limit_category"] == "litellm_rate_limit"
assert info["error_code"] == "429"
def test_should_propagate_vendor_category_for_plain_rate_limit_error(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
e = RateLimitError(
message="vendor 429",
llm_provider="openai",
model="gpt-4",
)
info = StandardLoggingPayloadSetup.get_error_information(e)
# Default category for a plain RateLimitError is vendor_rate_limit.
assert info["error_rate_limit_category"] == "vendor_rate_limit"
def test_should_propagate_litellm_batch_rate_limit_category(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
e = ProxyRateLimitError(
detail="batch over limit",
category=RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT,
)
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["error_rate_limit_category"] == "litellm_batch_rate_limit"
def test_should_be_none_for_non_rate_limit_errors(self):
# Non-rate-limit exceptions don't carry a `.category`; the field must
# be present (so consumers can do `info["error_rate_limit_category"]`
# unconditionally) but None.
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
info = StandardLoggingPayloadSetup.get_error_information(
ValueError("not a rate limit")
)
assert info["error_rate_limit_category"] is None
def test_should_be_none_when_no_exception(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
info = StandardLoggingPayloadSetup.get_error_information(None)
assert info["error_rate_limit_category"] is None
class TestProxyHooksActuallyRaiseProxyRateLimitError:
"""
End-to-end coverage tests that drive each refactored hook's rate-limit
branch and assert it raises a :class:`ProxyRateLimitError` carrying the
expected category. These complement the parametrized import-shape guard
above by actually executing the new ``raise ProxyRateLimitError(...)``
lines, so coverage tools see them as exercised.
"""
def test_parallel_request_limiter_v1_helper_raises_proxy_rate_limit_error(self):
"""v1 parallel_request_limiter has a sync ``raise_rate_limit_error``
helper used internally — it must raise the unified class."""
from unittest.mock import MagicMock
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
with pytest.raises(ProxyRateLimitError) as exc_info:
handler.raise_rate_limit_error(additional_details="key-over-rpm")
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
# The helper must populate retry-after so clients can back off.
assert e.headers is not None
assert "retry-after" in e.headers
# And it must still be catchable as HTTPException for FastAPI's
# default 429 dispatcher.
assert isinstance(e, HTTPException)
# The detail must include the additional_details suffix so operators
# can see why the limit was hit.
assert "key-over-rpm" in str(e.detail)
def test_parallel_request_limiter_v1_helper_no_additional_details(self):
"""
Regression guard: when ``raise_rate_limit_error`` is called WITHOUT
``additional_details``, the detail must NOT contain the literal
string ``"None"``. A long-standing bug had an unused ``error_message``
local variable masking an f-string that interpolated the raw
``additional_details`` arg directly; fixed in this PR's review pass.
"""
from unittest.mock import MagicMock
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
with pytest.raises(ProxyRateLimitError) as exc_info:
handler.raise_rate_limit_error() # no additional_details
detail_str = str(exc_info.value.detail)
assert "None" not in detail_str, (
f"detail must not embed literal 'None' when additional_details is "
f"omitted, got: {detail_str!r}"
)
assert detail_str == "Max parallel request limit reached"
def test_rate_limit_error_does_not_auto_copy_response_headers(self):
"""
Security regression guard: a vendor 429 response can set arbitrary
headers (Set-Cookie, CORS overrides, …). RateLimitError must NOT
auto-promote those into ``self.headers`` — only headers explicitly
passed via the ``headers=`` kwarg make it onto the attribute that
downstream proxy serializers may forward to the client. Vendor
response headers stay reachable on ``e.response.headers`` for
callers that explicitly want them.
"""
import httpx
vendor_response = httpx.Response(
status_code=429,
headers={"set-cookie": "evil=1; HttpOnly", "retry-after": "60"},
request=httpx.Request(method="POST", url="https://vendor.example/v1"),
)
e = RateLimitError(
message="vendor 429",
llm_provider="openai",
model="gpt-4",
response=vendor_response,
)
# Vendor headers must NOT have been copied onto self.headers.
assert e.headers is None
# They remain reachable on the underlying response for callers that
# opt in explicitly.
assert "set-cookie" in e.response.headers
# An explicit headers= kwarg, in contrast, IS surfaced on self.headers.
e2 = RateLimitError(
message="proxy 429",
llm_provider="litellm",
model="gpt-4",
response=vendor_response,
headers={"retry-after": "30"},
)
assert e2.headers == {"retry-after": "30"}
assert "set-cookie" not in (e2.headers or {})
def test_parallel_request_limiter_v3_handle_rate_limit_error_raises(self):
"""v3 parallel_request_limiter's ``_handle_rate_limit_error`` must
translate an OVER_LIMIT response into a ProxyRateLimitError."""
from unittest.mock import MagicMock
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_PROXY_MaxParallelRequestsHandler_v3,
)
handler = _PROXY_MaxParallelRequestsHandler_v3(internal_usage_cache=MagicMock())
# Minimal fabricated OVER_LIMIT response. The helper only reads a
# handful of fields off `status` and ignores everything else.
response = {
"overall_code": "OVER_LIMIT",
"statuses": [
{
"code": "OVER_LIMIT",
"descriptor_key": "key",
"current_limit": 10,
"limit_remaining": 0,
"rate_limit_type": "requests",
}
],
}
descriptors = [
{
"key": "key",
"value": "sk-test",
"rate_limit": {
"requests_per_unit": 10,
"tokens_per_unit": None,
"window_size": 60,
},
}
]
with pytest.raises(ProxyRateLimitError) as exc_info:
handler._handle_rate_limit_error(response, descriptors)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
# v3 helper attaches retry-after, rate_limit_type and reset_at.
assert e.headers is not None
assert {"retry-after", "rate_limit_type", "reset_at"}.issubset(e.headers.keys())
@pytest.mark.asyncio
async def test_max_iterations_limiter_raises_proxy_rate_limit_error(self):
"""
Drive `_PROXY_MaxIterationsHandler` past its session budget and assert
it raises the unified class. Mirrors the existing
`test_max_iterations_limiter.py` setup but pins down the new
`category` + dual-base contract on the raised instance.
"""
from unittest.mock import patch
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.max_iterations_limiter import (
_PROXY_MaxIterationsHandler,
)
from litellm.proxy.utils import InternalUsageCache
from litellm.types.agents import AgentResponse
cache = DualCache()
handler = _PROXY_MaxIterationsHandler(
internal_usage_cache=InternalUsageCache(cache),
)
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-iter",
agent_id="agent-iter-1",
)
agent = AgentResponse(
agent_id="agent-iter-1",
agent_name="iter-agent",
litellm_params={"max_iterations": 1},
agent_card_params={"name": "iter-agent", "version": "1.0.0"},
)
with patch(
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry"
) as mock_registry:
mock_registry.get_agent_by_id.return_value = agent
# First call within budget.
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={"metadata": {"session_id": "sess-1"}},
call_type="",
)
# Second call exceeds — must raise the unified class.
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=cache,
data={"metadata": {"session_id": "sess-1"}},
call_type="",
)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
assert isinstance(e, RateLimitError)
assert isinstance(e, HTTPException)
@pytest.mark.asyncio
async def test_max_budget_limiter_raises_proxy_rate_limit_error(self):
"""
Drive `_PROXY_MaxBudgetLimiter` past the user budget and assert it
raises the unified class. Mocks `get_current_spend` so we don't need
the proxy DB.
"""
from unittest.mock import patch
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.max_budget_limiter import (
_PROXY_MaxBudgetLimiter,
)
handler = _PROXY_MaxBudgetLimiter()
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-budget",
user_id="user-budget-1",
user_max_budget=1.0,
user_spend=2.0,
)
with patch(
"litellm.proxy.proxy_server.get_current_spend",
return_value=5.0,
):
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=DualCache(),
data={},
call_type="completion",
)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
assert "max budget" in str(e.detail).lower()
@pytest.mark.asyncio
async def test_dynamic_rate_limiter_v1_raises_proxy_rate_limit_error(self):
"""
Drive `_PROXY_DynamicRateLimitHandler` to raise via the available-TPM
path (`available_tpm == 0`) and assert it raises the unified class.
Mocks `check_available_usage` so we don't need a real router.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.dynamic_rate_limiter import (
_PROXY_DynamicRateLimitHandler,
)
handler = _PROXY_DynamicRateLimitHandler(internal_usage_cache=MagicMock())
# check_available_usage returns (available_tpm, available_rpm,
# model_tpm, model_rpm, active_projects). Setting available_tpm == 0
# forces the TPM-exceeded raise.
handler.check_available_usage = AsyncMock( # type: ignore[method-assign]
return_value=(0, 100, 1000, 100, 1)
)
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-dyn",
metadata={"priority": "default"},
)
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=DualCache(),
data={"model": "gpt-4"},
call_type="completion",
)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
assert isinstance(e.detail, dict)
assert "TPM" in e.detail.get("error", "")
@pytest.mark.asyncio
async def test_parallel_request_limiter_v1_check_key_in_limits_inline_raise(
self,
):
"""Cover the second raise site in v1 parallel_request_limiter
(`check_key_in_limits` else-branch) — fires when current usage already
meets the limits."""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
cache = MagicMock()
cache.async_batch_set_cache = AsyncMock(return_value=None)
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=cache)
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.check_key_in_limits(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-key"),
cache=DualCache(),
data={},
call_type="completion",
max_parallel_requests=1,
tpm_limit=10,
rpm_limit=10,
# current already at the limit on every dimension → forces
# the inline `raise ProxyRateLimitError(...)` else-branch.
current={"current_requests": 1, "current_tpm": 10, "current_rpm": 10},
request_count_api_key="x",
rate_limit_type="key",
values_to_update_in_cache=[],
)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
@pytest.mark.parametrize(
"current,limits,expected_type",
[
# current already at concurrent-request cap → CONCURRENT_REQUESTS
(
{"current_requests": 5, "current_tpm": 0, "current_rpm": 0},
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 100},
"concurrent_requests",
),
# current already at TPM cap (concurrent has headroom) → TOKENS
(
{"current_requests": 0, "current_tpm": 100, "current_rpm": 0},
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 100},
"tokens",
),
# current already at RPM cap (concurrent + TPM have headroom) →
# REQUESTS (the fall-through branch).
(
{"current_requests": 0, "current_tpm": 0, "current_rpm": 100},
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 100},
"requests",
),
],
)
@pytest.mark.asyncio
async def test_parallel_request_limiter_v1_inline_raise_dimension_detection(
self, current, limits, expected_type
):
"""
v1 parallel_request_limiter's `check_key_in_limits` else-branch must
attribute the raise to the dimension that actually tripped — not the
first dimension in declaration order.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
cache = MagicMock()
cache.async_batch_set_cache = AsyncMock(return_value=None)
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=cache)
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.check_key_in_limits(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-key"),
cache=DualCache(),
data={},
call_type="completion",
max_parallel_requests=limits["max_parallel_requests"],
tpm_limit=limits["tpm_limit"],
rpm_limit=limits["rpm_limit"],
current=current,
request_count_api_key="x",
rate_limit_type="key",
values_to_update_in_cache=[],
)
assert exc_info.value.rate_limit_type == expected_type
@pytest.mark.parametrize(
"limits,expected_type",
[
# max_parallel_requests = 0 → CONCURRENT_REQUESTS (most specific
# zero takes precedence per the helper's order).
(
{"max_parallel_requests": 0, "tpm_limit": 0, "rpm_limit": 0},
"concurrent_requests",
),
# tpm_limit = 0 (concurrent has a positive limit) → TOKENS
(
{"max_parallel_requests": 5, "tpm_limit": 0, "rpm_limit": 0},
"tokens",
),
# only rpm_limit = 0 → REQUESTS (fall-through)
(
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 0},
"requests",
),
],
)
@pytest.mark.asyncio
async def test_parallel_request_limiter_v1_base_case_dimension_detection(
self, limits, expected_type
):
"""
v1 parallel_request_limiter's `check_key_in_limits` base case
(``current is None`` and any limit set to 0) must attribute the raise
to the most-specific zero. This exercises the new dimension-detection
block that was missing patch coverage.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
cache = MagicMock()
cache.async_batch_set_cache = AsyncMock(return_value=None)
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=cache)
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.check_key_in_limits(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-key"),
cache=DualCache(),
data={},
call_type="completion",
max_parallel_requests=limits["max_parallel_requests"],
tpm_limit=limits["tpm_limit"],
rpm_limit=limits["rpm_limit"],
current=None, # base case
request_count_api_key="x",
rate_limit_type="key",
values_to_update_in_cache=[],
)
assert exc_info.value.rate_limit_type == expected_type
@pytest.mark.asyncio
async def test_dynamic_rate_limiter_v1_rpm_branch_raises(self):
"""Cover the RPM raise branch in v1 dynamic_rate_limiter (the TPM
branch is covered by the test above)."""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.dynamic_rate_limiter import (
_PROXY_DynamicRateLimitHandler,
)
handler = _PROXY_DynamicRateLimitHandler(internal_usage_cache=MagicMock())
# available_tpm > 0, available_rpm == 0 → RPM raise branch.
handler.check_available_usage = AsyncMock( # type: ignore[method-assign]
return_value=(100, 0, 1000, 100, 1)
)
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-dyn-rpm",
metadata={"priority": "default"},
)
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=DualCache(),
data={"model": "gpt-4"},
call_type="completion",
)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
assert isinstance(e.detail, dict)
assert "RPM" in e.detail.get("error", "")
@pytest.mark.parametrize(
"descriptor_key",
[
"model_saturation_check",
"priority_model",
"unknown_descriptor_for_fail_closed_fallback",
],
)
@pytest.mark.asyncio
async def test_dynamic_rate_limiter_v3_each_raise_branch(self, descriptor_key):
"""
Drive each of the three raise branches in v3 dynamic_rate_limiter:
model_saturation_check, priority_model, and the fail-closed fallback
for an unrecognized descriptor_key. Mocks
``atomic_check_and_increment_by_n`` so the v3 limiter's response
directly drives the raise-site selection.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.dynamic_rate_limiter_v3 import (
_PROXY_DynamicRateLimitHandlerV3,
)
# Bypass __init__ — we want to inject a stub v3_limiter without
# paying for the full handler setup.
handler = _PROXY_DynamicRateLimitHandlerV3.__new__(
_PROXY_DynamicRateLimitHandlerV3
)
v3_limiter = MagicMock()
v3_limiter.window_size = 60
v3_limiter.atomic_check_and_increment_by_n = AsyncMock(
return_value={
"overall_code": "OVER_LIMIT",
"statuses": [
{
"code": "OVER_LIMIT",
"descriptor_key": descriptor_key,
"current_limit": 100,
"limit_remaining": 0,
"rate_limit_type": "requests",
}
],
}
)
handler.v3_limiter = v3_limiter
# Stub the descriptor builders so we don't pull in real router state.
handler._create_model_tracking_descriptor = MagicMock( # type: ignore[method-assign]
return_value={
"key": descriptor_key,
"value": "v",
"rate_limit": {
"requests_per_unit": 100,
"tokens_per_unit": None,
"window_size": 60,
},
}
)
handler._create_priority_based_descriptors = MagicMock( # type: ignore[method-assign]
return_value=[]
)
model_group_info = MagicMock()
model_group_info.tpm = 1000
model_group_info.rpm = 100
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler._check_rate_limits(
model="gpt-4",
model_group_info=model_group_info,
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test-v3"),
priority="default",
saturation=0.99,
data={},
)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
@pytest.mark.asyncio
async def test_max_budget_per_session_limiter_raises_proxy_rate_limit_error(
self,
):
"""Drive `_PROXY_MaxBudgetPerSessionHandler` past its budget and
assert the unified class is raised."""
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.max_budget_per_session_limiter import (
_PROXY_MaxBudgetPerSessionHandler,
)
internal_cache = MagicMock()
internal_cache.async_get_cache = AsyncMock(return_value=10.0)
handler = _PROXY_MaxBudgetPerSessionHandler(
internal_usage_cache=internal_cache,
)
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-session",
agent_id="agent-session-1",
)
agent = MagicMock()
agent.litellm_params = {"max_budget_per_session": 1.0}
with patch(
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry"
) as mock_registry:
mock_registry.get_agent_by_id.return_value = agent
with pytest.raises(ProxyRateLimitError) as exc_info:
await handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=DualCache(),
data={"metadata": {"session_id": "session-over-budget"}},
call_type="completion",
)
e = exc_info.value
assert e.status_code == 429
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
assert "session" in str(e.detail).lower()
def test_batch_rate_limiter_helper_raises_with_litellm_batch_category(self):
"""
Direct invocation of `_PROXY_BatchRateLimiter._raise_rate_limit_error`
— confirms the batch limiter tags with `LITELLM_BATCH_RATE_LIMIT`
instead of the generic `LITELLM_RATE_LIMIT`.
"""
from unittest.mock import MagicMock
from litellm.proxy.hooks.batch_rate_limiter import (
BatchFileUsage,
_PROXY_BatchRateLimiter,
)
# Inject a parallel_request_limiter mock with a usable window_size so
# the helper's str(window_size) call doesn't NameError.
parallel_limiter = MagicMock()
parallel_limiter.window_size = 60
handler = _PROXY_BatchRateLimiter(
internal_usage_cache=MagicMock(),
parallel_request_limiter=parallel_limiter,
)
status = {
"code": "OVER_LIMIT",
"descriptor_key": "key",
"current_limit": 100,
"limit_remaining": 0,
"rate_limit_type": "requests",
}
descriptors = [
{
"key": "key",
"value": "sk-batch",
"rate_limit": {
"requests_per_unit": 100,
"tokens_per_unit": None,
"window_size": 60,
},
}
]
with pytest.raises(ProxyRateLimitError) as exc_info:
handler._raise_rate_limit_error(
status=status,
descriptors=descriptors,
batch_usage=BatchFileUsage(total_tokens=0, request_count=200),
limit_type="requests",
)
e = exc_info.value
assert e.status_code == 429
# Critical: batch category, NOT the default litellm_rate_limit.
assert e.category == RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
assert isinstance(e, RateLimitError)
assert isinstance(e, HTTPException)
class TestRateLimitType:
"""
Tests for the orthogonal `rate_limit_type` dimension introduced as a
follow-up to LIT-2968 (trho's last ask in the Slack thread).
`category` answers *who* rate-limited (vendor vs. litellm); `type`
answers *which dimension* was exceeded (requests / tokens / etc.).
Both are surfaced on the exception AND on the StandardLoggingPayload so
custom-metrics builders can split rate-limit failures by cause without
parsing free-text error messages.
"""
def test_should_export_type_enum_on_litellm_module(self):
assert hasattr(litellm, "RateLimitType")
assert litellm.RateLimitType is RateLimitType
def test_should_define_all_documented_types(self):
assert RateLimitType.REQUESTS == "requests"
assert RateLimitType.TOKENS == "tokens"
assert RateLimitType.CONCURRENT_REQUESTS == "concurrent_requests"
assert RateLimitType.BUDGET == "budget"
assert RateLimitType.MAX_ITERATIONS == "max_iterations"
def test_rate_limit_error_should_default_type_to_none(self):
# Existing callers (vendor 429s in exception_mapping_utils) construct
# RateLimitError without passing `rate_limit_type`. They typically
# don't have hard structured info on which dimension tripped, so
# default must be None — never an arbitrary value that would mislead
# dashboards.
e = RateLimitError(message="oops", llm_provider="openai", model="gpt-4")
assert e.rate_limit_type is None
def test_rate_limit_error_should_accept_string_type(self):
e = RateLimitError(
message="oops",
llm_provider="openai",
model="gpt-4",
rate_limit_type="tokens",
)
assert e.rate_limit_type == "tokens"
def test_rate_limit_error_should_accept_enum_type_and_normalize_to_string(self):
e = RateLimitError(
message="oops",
llm_provider="litellm",
model="gpt-4",
rate_limit_type=RateLimitType.CONCURRENT_REQUESTS,
)
# Same str-coercion guarantee we make for `category`: the attribute
# must serialize cleanly without enum-aware encoders downstream.
assert e.rate_limit_type == "concurrent_requests"
assert isinstance(e.rate_limit_type, str)
class TestProxyRateLimitErrorType:
def test_should_default_type_to_none(self):
# ProxyRateLimitError accepts but does not require a rate_limit_type.
# Callers that don't pass one (e.g. the simple Max-budget-limit-reached
# path that existed before this PR) must continue to construct fine.
e = ProxyRateLimitError(detail="over limit")
assert e.rate_limit_type is None
def test_should_carry_explicit_type(self):
e = ProxyRateLimitError(
detail="over limit",
rate_limit_type=RateLimitType.TOKENS,
)
assert e.rate_limit_type == "tokens"
def test_should_accept_string_type(self):
# The accepted-string form lets callers in modules that don't import
# the enum (e.g. v3 limiter passing through descriptor strings)
# forward the raw value.
e = ProxyRateLimitError(detail="over limit", rate_limit_type="budget")
assert e.rate_limit_type == "budget"
class TestMapV3RateLimitType:
"""The v3 limiter's internal labels collapse onto the public enum via
`map_v3_rate_limit_type`. These tests pin down each mapping so a future
refactor doesn't silently swap dimensions."""
def test_should_map_tokens(self):
assert map_v3_rate_limit_type("tokens") == RateLimitType.TOKENS
def test_should_map_requests(self):
assert map_v3_rate_limit_type("requests") == RateLimitType.REQUESTS
def test_should_map_max_parallel_requests_to_concurrent(self):
# The v3 limiter's internal jargon is `max_parallel_requests`, but
# the public-facing dimension is `concurrent_requests` (matches what
# users actually configure as `max_parallel_requests`). The mapping
# must collapse these so dashboards see one name, not two.
assert (
map_v3_rate_limit_type("max_parallel_requests")
== RateLimitType.CONCURRENT_REQUESTS
)
def test_should_return_none_for_unknown(self):
# Defensive: a v3 limiter shipping a new internal label must NOT
# silently coerce to a wrong public dimension. Returning None lets
# the caller decide (typically: omit the field).
assert map_v3_rate_limit_type("something_new") is None
assert map_v3_rate_limit_type(None) is None
class TestStandardLoggingPayloadCarriesType:
"""
The unified `rate_limit_type` must reach the structured logging payload
so custom callbacks can drive dashboards directly off
`StandardLoggingPayload.error_information.error_rate_limit_type`.
"""
def test_should_propagate_type_for_proxy_rate_limit_error(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
e = ProxyRateLimitError(
detail="over tpm",
rate_limit_type=RateLimitType.TOKENS,
)
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["error_rate_limit_type"] == "tokens"
def test_should_propagate_type_for_plain_rate_limit_error(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
e = RateLimitError(
message="vendor 429",
llm_provider="openai",
model="gpt-4",
rate_limit_type=RateLimitType.REQUESTS,
)
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["error_rate_limit_type"] == "requests"
def test_should_be_none_when_unspecified(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
# Vendor 429 exception with no header hints → type omitted.
e = RateLimitError(
message="vendor 429",
llm_provider="openai",
model="gpt-4",
)
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["error_rate_limit_type"] is None
def test_should_be_none_for_non_rate_limit_errors(self):
# Symmetry with `error_rate_limit_category`: the field must be
# present on every payload so consumers can read it
# unconditionally, but None for non-rate-limit exceptions.
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
info = StandardLoggingPayloadSetup.get_error_information(
ValueError("not a rate limit")
)
assert info["error_rate_limit_type"] is None
class TestProxyHooksWireTypeCorrectly:
"""
Each refactored hook must populate `rate_limit_type` with the dimension
that actually tripped the limit, so dashboards can split key/team/user
rate-limit failures by cause (RPM vs TPM vs concurrent vs budget vs
max-iterations) without grepping the error message.
"""
def test_max_budget_limiter_emits_budget_type(self):
e = ProxyRateLimitError(
detail="Max budget limit reached.",
rate_limit_type=RateLimitType.BUDGET,
)
assert e.category == "litellm_rate_limit"
assert e.rate_limit_type == "budget"
def test_max_iterations_limiter_emits_max_iterations_type(self):
e = ProxyRateLimitError(
detail="Max iterations exceeded for session abc.",
rate_limit_type=RateLimitType.MAX_ITERATIONS,
)
assert e.rate_limit_type == "max_iterations"
def test_max_budget_per_session_limiter_emits_budget_type(self):
e = ProxyRateLimitError(
detail="Session budget exceeded.",
rate_limit_type=RateLimitType.BUDGET,
)
assert e.rate_limit_type == "budget"
def test_parallel_request_limiter_v1_helper_emits_concurrent_default(self):
# When `raise_rate_limit_error` is called with no explicit type, the
# v1 helper defaults to CONCURRENT_REQUESTS (matches the historical
# message "Max parallel request limit reached"). Tests below cover
# the explicit-type override paths.
from unittest.mock import MagicMock
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
with pytest.raises(ProxyRateLimitError) as exc_info:
handler.raise_rate_limit_error()
assert exc_info.value.rate_limit_type == "concurrent_requests"
def test_parallel_request_limiter_v1_helper_accepts_explicit_type(self):
from unittest.mock import MagicMock
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
with pytest.raises(ProxyRateLimitError) as exc_info:
handler.raise_rate_limit_error(
additional_details="tpm-zero",
rate_limit_type=RateLimitType.TOKENS,
)
assert exc_info.value.rate_limit_type == "tokens"
def test_dynamic_rate_limiter_v1_tpm_path_emits_tokens_type(self):
# Sanity-check the v1 dynamic limiter wiring by constructing the
# exact exception the TPM-zero branch raises. We round-trip through
# ProxyRateLimitError to assert both fields. (Importing the limiter
# and wiring the full router setup would only re-test the
# pre-existing pre_call_hook — we already cover that elsewhere.)
e = ProxyRateLimitError(
detail={"error": "Key=k over available TPM=0."},
rate_limit_type=RateLimitType.TOKENS,
model="gpt-4",
)
assert e.rate_limit_type == "tokens"
assert e.model == "gpt-4"
def test_dynamic_rate_limiter_v1_rpm_path_emits_requests_type(self):
e = ProxyRateLimitError(
detail={"error": "Key=k over available RPM=0."},
rate_limit_type=RateLimitType.REQUESTS,
model="gpt-4",
)
assert e.rate_limit_type == "requests"
@pytest.mark.asyncio
async def test_v3_limiter_handle_rate_limit_error_propagates_type(self):
"""
End-to-end: feed the v3 limiter's `_handle_rate_limit_error` an
OVER_LIMIT response and verify the raised ProxyRateLimitError carries
the mapped public RateLimitType. This covers the actual
`map_v3_rate_limit_type(status["rate_limit_type"])` call site so
coverage tools see the new wiring as exercised.
"""
from unittest.mock import MagicMock
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_PROXY_MaxParallelRequestsHandler_v3,
)
handler = _PROXY_MaxParallelRequestsHandler_v3(
internal_usage_cache=MagicMock(),
)
# Minimal RateLimitResponse + descriptors shape that the handler
# reads. We only need one OVER_LIMIT status to drive the raise.
response = {
"overall_code": "OVER_LIMIT",
"statuses": [
{
"code": "OVER_LIMIT",
"descriptor_key": "key",
"current_limit": 100,
"limit_remaining": 0,
"rate_limit_type": "tokens",
}
],
}
descriptors = [
{
"key": "key",
"value": "sk-test",
"rate_limit": {
"requests_per_unit": None,
"tokens_per_unit": 100,
"window_size": 60,
},
}
]
with pytest.raises(ProxyRateLimitError) as exc_info:
handler._handle_rate_limit_error(
response=response,
descriptors=descriptors,
)
e = exc_info.value
# The public enum value, not the v3 internal "tokens" string per se —
# in this case they happen to coincide, but the next test pins down
# the renamed `max_parallel_requests` → `concurrent_requests` case.
assert e.rate_limit_type == "tokens"
# Wire-format invariants from the original PR still hold.
assert e.headers is not None
assert e.headers.get("rate_limit_type") == "tokens"
assert e.headers.get("retry-after") is not None
@pytest.mark.asyncio
async def test_v3_limiter_max_parallel_requests_maps_to_concurrent(self):
from unittest.mock import MagicMock
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_PROXY_MaxParallelRequestsHandler_v3,
)
handler = _PROXY_MaxParallelRequestsHandler_v3(
internal_usage_cache=MagicMock(),
)
response = {
"overall_code": "OVER_LIMIT",
"statuses": [
{
"code": "OVER_LIMIT",
"descriptor_key": "key",
"current_limit": 5,
"limit_remaining": 0,
# v3 internal jargon — must collapse to the public name.
"rate_limit_type": "max_parallel_requests",
}
],
}
descriptors = [
{
"key": "key",
"value": "sk-test",
"rate_limit": {
"requests_per_unit": None,
"tokens_per_unit": None,
"window_size": 60,
},
}
]
with pytest.raises(ProxyRateLimitError) as exc_info:
handler._handle_rate_limit_error(
response=response,
descriptors=descriptors,
)
# Public name on the enum field; raw header keeps the v3 jargon.
assert exc_info.value.rate_limit_type == "concurrent_requests"
assert exc_info.value.headers["rate_limit_type"] == "max_parallel_requests"
def test_batch_rate_limiter_emits_tokens_type_for_tpm_violation(self):
from unittest.mock import MagicMock
from litellm.proxy.hooks.batch_rate_limiter import (
BatchFileUsage,
_PROXY_BatchRateLimiter,
)
prl = MagicMock()
prl.window_size = 60
handler = _PROXY_BatchRateLimiter(
internal_usage_cache=MagicMock(),
parallel_request_limiter=prl,
)
status = {
"code": "OVER_LIMIT",
"descriptor_key": "key",
"current_limit": 1000,
"limit_remaining": 100,
"rate_limit_type": "tokens",
}
descriptors = [
{
"key": "key",
"value": "sk-test",
"rate_limit": {
"requests_per_unit": None,
"tokens_per_unit": 1000,
"window_size": 60,
},
}
]
with pytest.raises(ProxyRateLimitError) as exc_info:
handler._raise_rate_limit_error(
status=status,
descriptors=descriptors,
batch_usage=BatchFileUsage(total_tokens=500, request_count=0),
limit_type="tokens",
)
e = exc_info.value
assert e.rate_limit_type == "tokens"
assert e.category == RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
def test_batch_rate_limiter_emits_requests_type_for_rpm_violation(self):
from unittest.mock import MagicMock
from litellm.proxy.hooks.batch_rate_limiter import (
BatchFileUsage,
_PROXY_BatchRateLimiter,
)
prl = MagicMock()
prl.window_size = 60
handler = _PROXY_BatchRateLimiter(
internal_usage_cache=MagicMock(),
parallel_request_limiter=prl,
)
status = {
"code": "OVER_LIMIT",
"descriptor_key": "key",
"current_limit": 100,
"limit_remaining": 10,
"rate_limit_type": "requests",
}
descriptors = [
{
"key": "key",
"value": "sk-test",
"rate_limit": {
"requests_per_unit": 100,
"tokens_per_unit": None,
"window_size": 60,
},
}
]
with pytest.raises(ProxyRateLimitError) as exc_info:
handler._raise_rate_limit_error(
status=status,
descriptors=descriptors,
batch_usage=BatchFileUsage(total_tokens=0, request_count=200),
limit_type="requests",
)
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"
class TestThirdPartyAttrLeakageGuard:
"""
The duck-typed read at the StandardLoggingPayload + Prometheus surfaces
must reject `.category` / `.rate_limit_type` strings set on unrelated
third-party exceptions. Without validation, a foreign exception that
happens to declare either attribute name would leak garbage values into
custom-callback payloads and Prometheus label cardinality.
"""
def test_should_drop_unknown_category_string_on_third_party_exception(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
class Foreign(Exception):
category = "totally_not_a_real_category"
info = StandardLoggingPayloadSetup.get_error_information(Foreign("boom"))
assert info["error_rate_limit_category"] is None
def test_should_drop_unknown_rate_limit_type_string_on_third_party_exception(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
class Foreign(Exception):
rate_limit_type = "wat"
info = StandardLoggingPayloadSetup.get_error_information(Foreign("boom"))
assert info["error_rate_limit_type"] is None
def test_should_drop_non_string_garbage_attrs(self):
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
class Foreign(Exception):
category = 42
rate_limit_type = {"lol": "no"}
info = StandardLoggingPayloadSetup.get_error_information(Foreign())
assert info["error_rate_limit_category"] is None
assert info["error_rate_limit_type"] is None
def test_should_drop_garbage_on_prometheus_label_extraction(self):
from litellm.integrations.prometheus import PrometheusLogger
class Foreign(Exception):
category = "spam"
rate_limit_type = "spam"
category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(
Foreign()
)
assert category is None
assert rate_limit_type is None
def test_should_still_accept_legitimate_rate_limit_categories(self):
# The guard must not over-correct — every documented enum value
# is a valid string and must pass through.
from litellm.exceptions import (
validate_rate_limit_category,
validate_rate_limit_type,
)
for member in RateLimitErrorCategory:
assert validate_rate_limit_category(member.value) == member.value
assert validate_rate_limit_category(member) == member.value
for member in RateLimitType:
assert validate_rate_limit_type(member.value) == member.value
assert validate_rate_limit_type(member) == member.value
@pytest.mark.asyncio
class TestBudgetExceededErrorLlmProviderEnrichment:
"""
BudgetExceededError raise sites in auth_checks.py are tenant-scoped
(key / team / org / tag) and cannot see the request model. To still
populate `llm_provider` on the StandardLoggingPayload — which is what
custom-callback consumers attribute spend to — the central
UserAPIKeyAuthExceptionHandler enriches the exception from
`request_data["model"]` before post_call_failure_hook fires.
"""
async def _run_handler_and_capture_exception_seen_by_callback(
self, exception: Exception, request_data: dict
):
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.proxy.auth.auth_exception_handler import (
UserAPIKeyAuthExceptionHandler,
)
captured: dict = {}
async def fake_post_call_failure_hook(**kwargs):
captured["exception"] = kwargs["original_exception"]
return None
with (
patch(
"litellm.proxy.proxy_server.proxy_logging_obj",
MagicMock(
post_call_failure_hook=AsyncMock(
side_effect=fake_post_call_failure_hook
)
),
),
patch(
"litellm.proxy.proxy_server.general_settings",
{"use_x_forwarded_for": False},
),
patch(
"litellm.proxy.auth.auth_exception_handler._get_request_ip_address",
return_value="127.0.0.1",
),
):
try:
await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
e=exception,
request=MagicMock(),
request_data=request_data,
route="/v1/chat/completions",
parent_otel_span=None,
api_key="sk-test",
)
except Exception:
pass
return captured.get("exception")
async def test_should_resolve_llm_provider_from_request_data_when_unset(self):
err = litellm.BudgetExceededError(current_cost=100, max_budget=10)
assert err.llm_provider == ""
seen = await self._run_handler_and_capture_exception_seen_by_callback(
err, {"model": "openai/gpt-4o-mini"}
)
assert seen is not None
assert seen.llm_provider == "openai"
async def test_should_not_overwrite_llm_provider_when_caller_set_it(self):
err = litellm.BudgetExceededError(
current_cost=100, max_budget=10, llm_provider="anthropic"
)
seen = await self._run_handler_and_capture_exception_seen_by_callback(
err, {"model": "openai/gpt-4o-mini"}
)
assert seen.llm_provider == "anthropic"
async def test_should_fall_back_to_litellm_proxy_when_model_missing(self):
err = litellm.BudgetExceededError(current_cost=100, max_budget=10)
seen = await self._run_handler_and_capture_exception_seen_by_callback(err, {})
assert seen.llm_provider == "litellm_proxy"
async def test_should_not_enrich_non_budget_exceptions(self):
err = ValueError("unrelated")
seen = await self._run_handler_and_capture_exception_seen_by_callback(
err, {"model": "openai/gpt-4o-mini"}
)
assert not hasattr(seen, "llm_provider") or seen.llm_provider != "openai"