mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(exceptions): distinguish non-retryable insufficient_quota from retryable 429s
This commit is contained in:
parent
eb7e4a567a
commit
8b2d5a7f9a
6 changed files with 205 additions and 1 deletions
|
|
@ -1301,6 +1301,7 @@ from .exceptions import (
|
|||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
InsufficientQuotaError,
|
||||
RateLimitErrorCategory,
|
||||
RateLimitType,
|
||||
ServiceUnavailableError,
|
||||
|
|
|
|||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue