From 73609df1bf67509848f80b38b8ca220129c39e69 Mon Sep 17 00:00:00 2001 From: 72004 Date: Sun, 11 Oct 2026 19:03:17 +0500 Subject: [PATCH] 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. --- .../exception_mapping_utils.py | 48 +++++++++++++++---- .../test_exception_mapping_utils.py | 33 ++++++++++++- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 016a403281b..e6503c8636f 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -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, diff --git a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py index c54d1408c0a..1e71311d6c4 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -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