mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-20 05:04:24 +00:00
fix(exception_mapping): map OpenAI insufficient_quota to InsufficientQuotaError
Detect OpenAI insufficient_quota before the generic 429 rate-limit branch and raise InsufficientQuotaError instead of a plain RateLimitError. Promotion requires all of: custom_llm_provider == "openai" (this mapping path also serves ~30 openai-compatible providers, and only OpenAI is known to reserve insufficient_quota for a non-retryable billing state), an actual 429 status_code, and the structured error body code/type equal to "insufficient_quota". The substring check remains only as a cheap pre-filter: error messages echo request-controlled text (e.g. a schema named insufficient_quota), which must not be able to steer retry classification. A missing or unparseable body degrades to the plain retryable RateLimitError. Fixes #32785
This commit is contained in:
parent
b6c5fc0515
commit
5f653f34dc
1 changed files with 81 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ from ..exceptions import (
|
|||
BadRequestError,
|
||||
ContentPolicyViolationError,
|
||||
ContextWindowExceededError,
|
||||
InsufficientQuotaError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
|
|
@ -65,6 +66,29 @@ class ExceptionCheckers:
|
|||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_error_str_insufficient_quota(error_str: str) -> bool:
|
||||
"""
|
||||
Check if an error string indicates quota exhaustion (a billing state)
|
||||
rather than a transient rate limit.
|
||||
|
||||
OpenAI signals this with ``"type": "insufficient_quota"`` /
|
||||
``"code": "insufficient_quota"`` on a 429 response. This is NOT
|
||||
retryable — retrying a billing error cannot succeed — so it must be
|
||||
distinguished from a transient rate-limit 429. See
|
||||
https://github.com/BerriAI/litellm/issues/32785
|
||||
|
||||
Args:
|
||||
error_str: The error string to check
|
||||
|
||||
Returns:
|
||||
True if the error indicates insufficient quota, False otherwise
|
||||
"""
|
||||
if not isinstance(error_str, str):
|
||||
return False
|
||||
|
||||
return "insufficient_quota" in error_str.lower()
|
||||
|
||||
@staticmethod
|
||||
def is_error_str_context_window_exceeded(error_str: str) -> bool:
|
||||
"""
|
||||
|
|
@ -177,6 +201,38 @@ def _get_body_error_code(error_str: str) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _body_has_insufficient_quota_code(error_obj: object) -> bool:
|
||||
"""True only when the provider's STRUCTURED error body carries
|
||||
code or type == "insufficient_quota".
|
||||
|
||||
Reads the same sources ``get_error_message`` does: the openai SDK exposes
|
||||
``.code`` / ``.type`` parsed from the error response, plus ``.body`` as the
|
||||
parsed dict (OpenAI-style flat or Azure-style nested under "error"). A
|
||||
missing or unparseable body returns False, so callers degrade to the plain
|
||||
retryable RateLimitError rather than promoting on a free-text match.
|
||||
"""
|
||||
try:
|
||||
if error_obj is None:
|
||||
return False
|
||||
if getattr(error_obj, "code", None) == "insufficient_quota":
|
||||
return True
|
||||
if getattr(error_obj, "type", None) == "insufficient_quota":
|
||||
return True
|
||||
body = getattr(error_obj, "body", None)
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
nested = body.get("error")
|
||||
candidates = (
|
||||
body.get("code"),
|
||||
body.get("type"),
|
||||
nested.get("code") if isinstance(nested, dict) else None,
|
||||
nested.get("type") if isinstance(nested, dict) else None,
|
||||
)
|
||||
return "insufficient_quota" in candidates
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_response_headers(original_exception: Exception) -> Optional[httpx.Headers]:
|
||||
"""
|
||||
Extract and return the response headers from an exception, if present.
|
||||
|
|
@ -280,6 +336,31 @@ def _map_openai_exception(
|
|||
else:
|
||||
exception_provider = custom_llm_provider[0].upper() + custom_llm_provider[1:] + "Exception"
|
||||
|
||||
if (
|
||||
custom_llm_provider == "openai"
|
||||
and ExceptionCheckers.is_error_str_insufficient_quota(error_str)
|
||||
and getattr(original_exception, "status_code", None) == 429
|
||||
and _body_has_insufficient_quota_code(original_exception)
|
||||
):
|
||||
# Quota exhaustion (a non-retryable billing state) must be caught BEFORE
|
||||
# the generic 429 rate-limit branch, which would otherwise bucket it as a
|
||||
# transient RateLimitError. Gated to the openai provider: this function
|
||||
# also serves ~30 openai-compatible providers (groq, deepseek, together_ai,
|
||||
# ...) and only OpenAI is known to reserve "insufficient_quota" for a
|
||||
# non-retryable billing state; a compatible provider using the same wording
|
||||
# for a refilling quota would silently lose retries. Promotion further
|
||||
# requires a real 429 plus the structured body code/type, never the
|
||||
# substring alone: error messages echo request-controlled text (e.g. a
|
||||
# schema named "insufficient_quota"), which must not be able to steer
|
||||
# retry classification (see the PR discussion on spoofability). The
|
||||
# substring check stays as a cheap pre-filter only. See
|
||||
# https://github.com/BerriAI/litellm/issues/32785
|
||||
raise InsufficientQuotaError(
|
||||
message=f"{exception_provider} - {message}",
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
if ExceptionCheckers.is_error_str_rate_limit(error_str):
|
||||
raise RateLimitError(
|
||||
message=f"RateLimitError: {exception_provider} - {message}",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue