fix(anthropic): only treat the upstream error body as a usage limit

Locally raised validation errors echo the caller's messages, so matching
"api usage limit" anywhere in the error string let request content turn a
400 into a 429 and cool down shared deployments. Parse Anthropic's
structured error body and check error.message instead, and drop the
substring check from the provider-agnostic rate-limit helper.
This commit is contained in:
72004 2026-10-11 19:03:17 +05:00
parent c2b5b09080
commit 73609df1bf
2 changed files with 70 additions and 11 deletions

View file

@ -3,8 +3,9 @@ import json
import re
import traceback
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Protocol, cast
from typing import Final, Literal, Protocol, cast
import httpx
from pydantic import TypeAdapter, ValidationError
@ -38,11 +39,46 @@ from ..exceptions import (
)
@dataclass(frozen=True, slots=True)
class _AnthropicErrorDetail:
message: str
@dataclass(frozen=True, slots=True)
class _AnthropicErrorBody:
type: Literal["error"]
error: _AnthropicErrorDetail
_ANTHROPIC_ERROR_BODY: Final = TypeAdapter(_AnthropicErrorBody)
class ExceptionCheckers:
"""
Helper class for checking various error conditions in exception strings.
"""
@staticmethod
def is_anthropic_usage_limit_error(error_str: str) -> bool:
"""
Anthropic reports spend/usage-limit exhaustion as an invalid_request_error
(HTTP 400), e.g. "You have reached your specified API usage limits." (#37599)
Only the structured upstream error body is inspected, never the raw string:
locally raised validation errors echo the caller's messages, and matching on
those would let request content turn a 400 into a 429 and cool down shared
deployments.
"""
start: Final = error_str.find("{")
if start == -1:
return False
try:
end: Final = json.JSONDecoder().raw_decode(error_str, start)[1]
body: Final = _ANTHROPIC_ERROR_BODY.validate_json(error_str[start:end])
except ValueError: # includes json.JSONDecodeError and pydantic.ValidationError
return False
return "api usage limit" in body.error.message.lower()
@staticmethod
def is_error_str_rate_limit(error_str: str, status_code: int | None = None) -> bool:
"""
@ -78,14 +114,6 @@ class ExceptionCheckers:
if "service tier capacity exceeded" in _error_str_lower:
return True
#######################################
# Anthropic returns spend/usage-limit exhaustion typed as an
# invalid_request_error (HTTP 400), e.g. "You have reached your specified
# API usage limits." It is a usage limit and should map to 429. (#37599)
#######################################
if "api usage limit" in _error_str_lower:
return True
return False
@staticmethod
@ -601,7 +629,7 @@ def _map_anthropic_exception(
# Anthropic returns usage/spend-limit exhaustion typed as invalid_request_error (400),
# but per the exception-mapping docs it should surface as a RateLimitError (429). Check
# before the generic 400 branches below, which would otherwise map it to BadRequestError.
if ExceptionCheckers.is_error_str_rate_limit(error_str, getattr(original_exception, "status_code", None)):
if ExceptionCheckers.is_anthropic_usage_limit_error(error_str):
raise RateLimitError(
message=f"AnthropicError - {error_str}",
model=model,

View file

@ -216,7 +216,38 @@ class TestExceptionCheckers:
'"message":"You have reached your specified API usage limits. '
'You will regain access on 2026-09-01 at 00:00 UTC."}}'
)
assert ExceptionCheckers.is_error_str_rate_limit(error_str, status_code=400) is True
assert ExceptionCheckers.is_anthropic_usage_limit_error(error_str) is True
def test_echoed_request_content_is_not_an_anthropic_usage_limit(self):
"""A local validation failure echoes the caller's messages. Usage-limit text in
that echoed content must not be mistaken for the upstream error and promoted to
a 429, which would cool down shared deployments.
"""
error_str = (
"AnthropicException - Unsupported file content\n"
"Received Messages=[{'role': 'user', 'content': ["
"{'type': 'text', 'text': 'api usage limit'}, {'type': 'file', 'file': {}}]}]"
)
assert ExceptionCheckers.is_anthropic_usage_limit_error(error_str) is False
assert ExceptionCheckers.is_error_str_rate_limit(error_str, status_code=400) is False
def test_echoed_request_content_still_maps_to_bad_request(self):
error_str = (
"Unsupported file content\n"
"Received Messages=[{'role': 'user', 'content': '"
'{"type":"error","error":{"message":"api usage limit"}}'
"'}]"
)
from litellm.llms.anthropic.common_utils import AnthropicError
original_exception = AnthropicError(status_code=400, message=error_str)
with pytest.raises(litellm.BadRequestError):
exception_type(
model="claude-haiku-4-5-20251001",
original_exception=original_exception,
custom_llm_provider="anthropic",
)
def test_anthropic_usage_limit_maps_to_rate_limit_error_end_to_end(self):
"""Production path: an Anthropic usage-limit error is typed invalid_request_error