From 8b2d5a7f9acf1a9cd54f6fef64bd29cdf44602af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:17:45 +0000 Subject: [PATCH] fix(exceptions): distinguish non-retryable insufficient_quota from retryable 429s --- litellm/__init__.py | 1 + litellm/exceptions.py | 47 +++++++++++ .../exception_mapping_utils.py | 27 ++++++- litellm/router.py | 3 + .../test_exception_mapping_utils.py | 80 +++++++++++++++++++ .../test_router_retry_non_retryable_errors.py | 48 +++++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 6e2a03b7c7c..2c961f470cb 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1301,6 +1301,7 @@ from .exceptions import ( NotFoundError, PermissionDeniedError, RateLimitError, + InsufficientQuotaError, RateLimitErrorCategory, RateLimitType, ServiceUnavailableError, diff --git a/litellm/exceptions.py b/litellm/exceptions.py index aca3fb551cc..1c0640c00fe 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -38,6 +38,9 @@ class RateLimitErrorCategory(str, enum.Enum): VENDOR_RATE_LIMIT = "vendor_rate_limit" """The upstream LLM provider returned a rate-limit response (e.g. OpenAI 429).""" + VENDOR_INSUFFICIENT_QUOTA = "vendor_insufficient_quota" + """The upstream LLM provider rejected the request because the account's billing quota is exhausted (e.g. OpenAI ``insufficient_quota``). Non-retryable — retrying cannot clear the condition.""" + VENDOR_BATCH_RATE_LIMIT = "vendor_batch_rate_limit" """The upstream LLM provider returned a rate-limit response on a batch endpoint.""" @@ -500,6 +503,50 @@ class RateLimitError(openai.RateLimitError): # type: ignore return _message +class InsufficientQuotaError(RateLimitError): + """ + Non-retryable provider billing/quota-exhaustion error. + + Providers surface an exhausted billing quota as an HTTP 429 — OpenAI sends + ``code: "insufficient_quota"`` — which is byte-identical on the wire to a + transient rate-limit 429. The two are very different though: a transient 429 + clears on its own and is worth retrying, while a quota-exhaustion 429 stays + until the account tops up its balance, so retrying only produces retry storms + and delays the actionable "check your plan and billing" message. + + This subclass lets callers tell the two apart. It still derives from + :class:`RateLimitError`, so existing ``except RateLimitError`` handlers keep + catching it, while retry policies can special-case it and skip retrying. + """ + + def __init__( + self, + message, + llm_provider, + model, + response: Optional[httpx.Response] = None, + litellm_debug_info: Optional[str] = None, + max_retries: Optional[int] = None, + num_retries: Optional[int] = None, + headers: Optional[Dict[str, str]] = None, + detail: Any = None, + ): + super().__init__( + message=message, + llm_provider=llm_provider, + model=model, + response=response, + litellm_debug_info=litellm_debug_info, + max_retries=max_retries, + num_retries=num_retries, + category=RateLimitErrorCategory.VENDOR_INSUFFICIENT_QUOTA, + headers=headers, + detail=detail, + ) + self.code = "insufficient_quota" + self.type = "insufficient_quota" + + # sub class of rate limit error - meant to give more granularity for error handling context window exceeded errors class ContextWindowExceededError(BadRequestError): # type: ignore def __init__( diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index fdab3d5b9d4..931df38b1d5 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,20 @@ class ExceptionCheckers: return False + @staticmethod + def is_error_str_insufficient_quota(error_str: str) -> bool: + """ + Check if an error string indicates an exhausted billing quota. + + Providers return this as an HTTP 429 that is indistinguishable from a + transient rate limit by status code alone, so the provider-specific + ``insufficient_quota`` marker is the only reliable signal. Unlike a + transient rate limit, retrying cannot clear it. + """ + 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: """ @@ -280,7 +295,17 @@ def _map_openai_exception( else: exception_provider = custom_llm_provider[0].upper() + custom_llm_provider[1:] + "Exception" - if ExceptionCheckers.is_error_str_rate_limit(error_str): + if ExceptionCheckers.is_error_str_insufficient_quota(error_str) or ( + getattr(original_exception, "code", None) == "insufficient_quota" + ): + raise InsufficientQuotaError( + message=f"InsufficientQuotaError: {exception_provider} - {message}", + model=model, + llm_provider=custom_llm_provider, + response=getattr(original_exception, "response", None), + litellm_debug_info=extra_information, + ) + elif ExceptionCheckers.is_error_str_rate_limit(error_str): raise RateLimitError( message=f"RateLimitError: {exception_provider} - {message}", model=model, diff --git a/litellm/router.py b/litellm/router.py index 5ffe60c2da0..027c3858303 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -6599,6 +6599,9 @@ class Router: if isinstance(error, litellm.ContentPolicyViolationError) and content_policy_fallbacks is not None: raise error + if isinstance(error, litellm.InsufficientQuotaError): + raise error + status_code = getattr(error, "status_code", None) if status_code is not None and not litellm._should_retry(status_code): # 401/403 are special cases - allow retry if multiple deployments exist (handled below) diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index 1fcee1b1c42..f663be80b5f 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -133,6 +133,22 @@ class TestExceptionCheckers: result = ExceptionCheckers.is_error_str_rate_limit(error_str) assert result is True + def test_is_error_str_insufficient_quota_detects_quota_marker(self): + """A serialized OpenAI 429 body carrying the insufficient_quota marker must be detected""" + + error_str = ( + "Error code: 429 - {'error': {'message': 'You exceeded your current quota, " + "please check your plan and billing details.', 'type': 'insufficient_quota', " + "'param': None, 'code': 'insufficient_quota'}}" + ) + assert ExceptionCheckers.is_error_str_insufficient_quota(error_str) is True + + def test_is_error_str_insufficient_quota_ignores_transient_rate_limit(self): + """A transient rate-limit 429 without the quota marker must not be treated as quota exhaustion""" + + error_str = "RateLimitError: OpenAIException - Rate limit reached (status code 429)" + assert ExceptionCheckers.is_error_str_insufficient_quota(error_str) is False + def test_is_azure_content_policy_violation_error_with_policy_violation_text(self): """Test detection of Azure content policy violation with explicit policy violation text""" @@ -373,6 +389,70 @@ def test_vertex_ai_rate_limit_error_mapping(error_message, should_raise_rate_lim ) +def test_openai_insufficient_quota_maps_to_insufficient_quota_error(): + """ + An OpenAI 429 carrying code/type ``insufficient_quota`` (an exhausted billing + quota) must map to the non-retryable litellm.InsufficientQuotaError rather than + the retryable litellm.RateLimitError. + + Regression for https://github.com/BerriAI/litellm/issues/32785 + """ + model = "gpt-5.5" + error_message = ( + "Error code: 429 - {'error': {'message': 'You exceeded your current quota, " + "please check your plan and billing details.', 'type': 'insufficient_quota', " + "'param': None, 'code': 'insufficient_quota'}}" + ) + original_exception = OpenAIError( + status_code=429, + message=error_message, + headers={}, + ) + + with pytest.raises(litellm.InsufficientQuotaError) as excinfo: + exception_type( + model=model, + original_exception=original_exception, + custom_llm_provider="openai", + ) + + err = excinfo.value + assert isinstance(err, litellm.RateLimitError) + assert err.status_code == 429 + assert err.category == litellm.RateLimitErrorCategory.VENDOR_INSUFFICIENT_QUOTA.value + assert err.code == "insufficient_quota" + assert err.type == "insufficient_quota" + + +def test_openai_transient_rate_limit_stays_rate_limit_error(): + """ + A transient OpenAI 429 (no insufficient_quota marker) must remain a plain + retryable litellm.RateLimitError, never the InsufficientQuotaError subclass. + """ + model = "gpt-5.5" + error_message = ( + "Error code: 429 - {'error': {'message': 'Rate limit reached for gpt-5.5 in " + "organization org-xxxx on requests per min.', 'type': 'requests', " + "'param': None, 'code': 'rate_limit_exceeded'}}" + ) + original_exception = OpenAIError( + status_code=429, + message=error_message, + headers={}, + ) + + with pytest.raises(litellm.RateLimitError) as excinfo: + exception_type( + model=model, + original_exception=original_exception, + custom_llm_provider="openai", + ) + + err = excinfo.value + assert not isinstance(err, litellm.InsufficientQuotaError) + assert err.category == litellm.RateLimitErrorCategory.VENDOR_RATE_LIMIT.value + + class TestGetBodyErrorCode: """Unit tests for _get_body_error_code helper.""" diff --git a/tests/test_litellm/test_router_retry_non_retryable_errors.py b/tests/test_litellm/test_router_retry_non_retryable_errors.py index 0728947eafe..45dd935eaf4 100644 --- a/tests/test_litellm/test_router_retry_non_retryable_errors.py +++ b/tests/test_litellm/test_router_retry_non_retryable_errors.py @@ -54,6 +54,17 @@ def _make_not_found_error(message="Model not found"): ) +def _make_insufficient_quota_error( + message="You exceeded your current quota, please check your plan and billing details.", +): + """Create an InsufficientQuotaError for testing.""" + return litellm.InsufficientQuotaError( + message=message, + llm_provider="openai", + model="gpt-4", + ) + + def _create_router(num_retries=2): """Create a Router with two deployments for testing.""" return Router( @@ -236,6 +247,43 @@ async def test_retryable_errors_still_retry_normally(): assert call_count == 4 +@pytest.mark.asyncio +async def test_insufficient_quota_error_not_retried(): + """ + An exhausted provider billing quota (InsufficientQuotaError) is non-retryable + even though it is a 429 - retrying the same key cannot clear it. The router must + surface it after the very first call instead of burning the retry budget on it. + + Regression for https://github.com/BerriAI/litellm/issues/32785 + """ + router = _create_router(num_retries=3) + + call_count = 0 + + async def mock_make_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + raise _make_insufficient_quota_error() + + with ( + patch.object(router, "make_call", side_effect=mock_make_call), + patch.object( + router, + "_async_get_healthy_deployments", + return_value=(["d1", "d2"], ["d1", "d2"]), + ), + patch.object(router, "_time_to_sleep_before_retry", return_value=0), + patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs), + ): + with pytest.raises(litellm.InsufficientQuotaError): + await router.async_function_with_retries( + num_retries=3, + **_base_kwargs(), + ) + + assert call_count == 1 + + @pytest.mark.asyncio async def test_not_found_error_in_retry_loop_raises_immediately(): """