From 5f653f34dc3b3447ce4ed114bacdfa6db673ebd2 Mon Sep 17 00:00:00 2001 From: dparkmit24 <163079241+dparkmit24@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:56:43 +0000 Subject: [PATCH] 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 --- .../exception_mapping_utils.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index fdab3d5b9d4..e85f6de6a6c 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -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}",