fix(exceptions): distinguish non-retryable insufficient_quota from retryable 429s

This commit is contained in:
Devin AI 2026-07-10 23:17:45 +00:00
parent eb7e4a567a
commit 8b2d5a7f9a
6 changed files with 205 additions and 1 deletions

View file

@ -1301,6 +1301,7 @@ from .exceptions import (
NotFoundError,
PermissionDeniedError,
RateLimitError,
InsufficientQuotaError,
RateLimitErrorCategory,
RateLimitType,
ServiceUnavailableError,

View file

@ -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__(

View file

@ -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,

View file

@ -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)

View file

@ -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."""

View file

@ -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():
"""