From dfb7f756b9e903b6148742b258cb9a5b4e6dcfdd Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Fri, 28 Aug 2026 23:28:56 +0200 Subject: [PATCH 01/13] fix(exception_mapping): expand vendor rate limit and quota exhaustion patterns (#37599) - Support quota exceeded, resource_exhausted, throughput exceeded, and concurrency limit phrases across Gemini, Bedrock, Mistral, Azure, and Anthropic - Add unit tests in test_exception_mapping_utils.py verifying vendor-specific rate limit detection under 400 and non-429 status codes --- .../exception_mapping_utils.py | 25 ++++++++++++++++--- .../test_exception_mapping_utils.py | 17 +++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 8f8c955d971..6b35169af33 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -62,10 +62,27 @@ class ExceptionCheckers: if re.search(r"rate[\s_\-]*limit", _error_str_lower): return True - ####################################### - # Mistral API returns this error string - ######################################### - if "service tier capacity exceeded" in _error_str_lower: + # Common provider rate-limit and quota exhaustion phrases across Gemini, Bedrock, Mistral, Azure, and Anthropic + rate_limit_patterns: Final = ( + "service tier capacity exceeded", + "too many requests", + "quota exceeded", + "quota_exceeded", + "insufficient_quota", + "exceeded your current quota", + "resource_exhausted", + "resource has been exhausted", + "request_rate_too_large", + "provisioned throughput exceeded", + "capacity temporarily exceeded", + "concurrency limit reached", + "concurrent requests limit exceeded", + "requests per minute exceeded", + "tokens per minute exceeded", + "requests per day exceeded", + "tokens per day exceeded", + ) + if any(pattern in _error_str_lower for pattern in rate_limit_patterns): return True return False 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 15b7ae9d07a..0cd238150cc 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 @@ -164,6 +164,23 @@ class TestExceptionCheckers: is True ) + def test_rate_limit_provider_specific_phrases(self): + """Test that common vendor rate-limit and quota exhaustion phrases are properly recognized.""" + vendor_phrases = [ + "GeminiException - Resource has been exhausted (e.g. check quota)", + "VertexAIException - resource_exhausted: quota exceeded for default tier", + "OpenAIException - insufficient_quota: You exceeded your current quota", + "BedrockException - provisioned throughput exceeded for model", + "DatabricksException - request_rate_too_large", + "TogetherAIException - concurrency limit reached: max 20 concurrent requests", + "GroqException - tokens per minute exceeded (TPM limit)", + "AnthropicException - capacity temporarily exceeded", + ] + for phrase in vendor_phrases: + assert ( + ExceptionCheckers.is_error_str_rate_limit(phrase, status_code=400) is True + ), f"Failed to recognize rate limit in phrase: {phrase}" + 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""" From fcfcba3e34b0ce34947afb12f51eac5704825716 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Fri, 28 Aug 2026 23:40:25 +0200 Subject: [PATCH 02/13] fix(exception_mapping): integrate rate limit checks across Anthropic, Bedrock, Vertex, and Together mappers - Hook ExceptionCheckers.is_error_str_rate_limit into _map_anthropic_exception, _map_bedrock_exception, _map_vertex_exception, and _map_together_ai_exception - Use immutable tuple for test vendor_phrases per repository conventions --- .../exception_mapping_utils.py | 33 +++++++++++++++++++ .../test_exception_mapping_utils.py | 4 +-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 6b35169af33..ad77f3a99e5 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -541,6 +541,16 @@ def _map_anthropic_exception( model=model, llm_provider="anthropic", ) + elif ExceptionCheckers.is_error_str_rate_limit( + error_str, status_code=getattr(original_exception, "status_code", None) + ): + raise RateLimitError( + message=f"AnthropicException - {error_str}", + model=model, + llm_provider="anthropic", + response=getattr(original_exception, "response", None), + litellm_debug_info=extra_information, + ) if "Invalid API Key" in error_str: raise AuthenticationError( message=f"AnthropicError - {error_str}", @@ -891,6 +901,16 @@ def _map_bedrock_exception( model=model, llm_provider="bedrock", ) + elif ExceptionCheckers.is_error_str_rate_limit( + error_str, status_code=getattr(original_exception, "status_code", None) + ): + raise RateLimitError( + message=f"BedrockException - {error_str}", + model=model, + llm_provider="bedrock", + response=getattr(original_exception, "response", None), + litellm_debug_info=extra_information, + ) elif "Conversation blocks and tool result blocks cannot be provided in the same turn." in error_str: raise BadRequestError( message=f"BedrockException - {error_str}\n. Enable 'litellm.modify_params=True' (for PROXY do: `litellm_settings::modify_params: True`) to insert a dummy assistant message and fix this error.", @@ -1221,6 +1241,9 @@ def _map_vertex_exception( or "Resource exhausted" in error_str or "IndexError: list index out of range" in error_str or "429 Unable to submit request because the service is temporarily out of capacity." in error_str + or ExceptionCheckers.is_error_str_rate_limit( + error_str, status_code=getattr(original_exception, "status_code", None) + ) ): raise RateLimitError( message=f"litellm.RateLimitError: {custom_llm_provider}Exception - {error_str}", @@ -1742,6 +1765,16 @@ def _map_together_ai_exception( model=model, llm_provider="together_ai", ) + elif ExceptionCheckers.is_error_str_rate_limit( + error_str, status_code=getattr(original_exception, "status_code", None) + ): + raise RateLimitError( + message=f"TogetherAIException - {error_str}", + model=model, + llm_provider="together_ai", + response=getattr(original_exception, "response", None), + litellm_debug_info=extra_information, + ) elif ( "error" in error_response and "API key doesn't match expected format." in error_response["error"] 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 0cd238150cc..234f7dfc229 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 @@ -166,7 +166,7 @@ class TestExceptionCheckers: def test_rate_limit_provider_specific_phrases(self): """Test that common vendor rate-limit and quota exhaustion phrases are properly recognized.""" - vendor_phrases = [ + vendor_phrases: Final = ( "GeminiException - Resource has been exhausted (e.g. check quota)", "VertexAIException - resource_exhausted: quota exceeded for default tier", "OpenAIException - insufficient_quota: You exceeded your current quota", @@ -175,7 +175,7 @@ class TestExceptionCheckers: "TogetherAIException - concurrency limit reached: max 20 concurrent requests", "GroqException - tokens per minute exceeded (TPM limit)", "AnthropicException - capacity temporarily exceeded", - ] + ) for phrase in vendor_phrases: assert ( ExceptionCheckers.is_error_str_rate_limit(phrase, status_code=400) is True From e7000d63b299f6be13189ebe18b05b15d7437e39 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Fri, 28 Aug 2026 23:46:03 +0200 Subject: [PATCH 03/13] fix(tests): import Final from typing in test_exception_mapping_utils --- .../litellm_core_utils/test_exception_mapping_utils.py | 2 ++ 1 file changed, 2 insertions(+) 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 234f7dfc229..624594492be 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 @@ -1,4 +1,6 @@ +from typing import Final + import httpx import openai import pytest From 31a70c675dac1a7604dafb61c32350bcea9d55e1 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Sat, 29 Aug 2026 15:04:23 +0200 Subject: [PATCH 04/13] fix(exception_mapping): cover Azure rate-limit phrases --- .../exception_mapping_utils.py | 10 ++++++++ .../test_exception_mapping_utils.py | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index ad77f3a99e5..035de4798e4 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2040,6 +2040,16 @@ def _map_azure_exception( response=getattr(original_exception, "response", None), body=getattr(original_exception, "body", None), ) + elif ExceptionCheckers.is_error_str_rate_limit( + error_str, status_code=getattr(original_exception, "status_code", None) + ): + raise RateLimitError( + message=f"AzureException RateLimitError - {message}", + llm_provider="azure", + model=model, + litellm_debug_info=extra_information, + response=getattr(original_exception, "response", None), + ) elif "invalid_request_error" in error_str and getattr(original_exception, "status_code", None) in (None, 400): raise BadRequestError( message=f"AzureException BadRequestError - {message}", 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 624594492be..10d8815ed50 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 @@ -1083,6 +1083,30 @@ class _UpstreamErrorWithMessage(_UpstreamHTTPError): ) +@pytest.mark.parametrize( + ("provider", "error_message"), + [ + ("gemini", "GeminiException - resource has been exhausted (e.g. check quota)"), + ("vertex_ai", "VertexAIException - resource_exhausted: quota exceeded for default tier"), + ("bedrock", "BedrockException - provisioned throughput exceeded for model"), + ("azure", "AzureException - request_rate_too_large"), + ("anthropic", "AnthropicException - capacity temporarily exceeded"), + ], +) +def test_provider_rate_limit_phrases_reach_dispatch_boundary( + provider, error_message, quiet_exception_mapping +): + """Vendor quota messages must reach the provider mapper as RateLimitError.""" + with pytest.raises(litellm.RateLimitError) as raised: + exception_type( + model="test-model", + original_exception=_UpstreamErrorWithMessage(error_message, 400), + custom_llm_provider=provider, + ) + + assert raised.value.llm_provider == provider + + @pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER) def test_a_full_context_window_reaches_the_caller_as_the_router_needs_it( provider, quiet_exception_mapping From 126675858449046d3be26f5c979dbbeb70dbacf6 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Sat, 29 Aug 2026 15:32:53 +0200 Subject: [PATCH 05/13] fix(exception_mapping): ignore validation echoes in rate limits --- .../exception_mapping_utils.py | 156 +++++++++++++++++- .../test_exception_mapping_utils.py | 88 ++++++++++ 2 files changed, 239 insertions(+), 5 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 035de4798e4..2a73ed0808c 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -28,13 +28,137 @@ from ..exceptions import ( ) +_RATE_LIMIT_STRUCTURED_MARKERS: Final = frozenset( + { + "429", + "capacity_temporarily_exceeded", + "concurrency_limit_reached", + "concurrent_requests_limit_exceeded", + "insufficient_quota", + "provisioned_throughput_exceeded", + "quota_exceeded", + "rate_limit", + "rate_limit_error", + "request_rate_too_large", + "requests_per_day_exceeded", + "requests_per_minute_exceeded", + "resource_exhausted", + "throttling_exception", + "tokens_per_day_exceeded", + "tokens_per_minute_exceeded", + "too_many_requests", + } +) +_VALIDATION_STRUCTURED_MARKERS: Final = frozenset( + { + "bad_request", + "invalid_argument", + "invalid_request", + "invalid_request_error", + "validation_error", + "validation_exception", + } +) +_STRUCTURED_ERROR_SIGNAL_KEYS: Final = ( + "code", + "error_code", + "status", + "status_code", + "type", + "error_type", + "__type", + "reason", +) + + +def _normalise_structured_marker(value: Any) -> str: + """Normalize provider code/type values for marker comparison.""" + if not isinstance(value, (int, str)): + return "" + value_string = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", str(value)) + return re.sub(r"[^a-z0-9]+", "_", value_string.lower()).strip("_") + + +def _parse_structured_error_payload(error_text: str) -> dict[str, Any] | None: + """Parse a JSON error body, including SDK prefixes around the JSON.""" + candidates = [error_text.strip()] + start = error_text.find("{") + end = error_text.rfind("}") + if start >= 0 and end > start: + candidates.append(error_text[start : end + 1]) + + for candidate in candidates: + try: + payload = json.loads(candidate) + except (TypeError, ValueError): + continue + if isinstance(payload, dict): + return payload + return None + + +def _structured_rate_limit_signal( + *, + error_str: str, + response: httpx.Response | None = None, + error_body: Any | None = None, +) -> bool | None: + """Return an explicit rate-limit/validation signal when the provider supplies one.""" + payloads: list[dict[str, Any]] = [] + if isinstance(error_body, dict): + payloads.append(error_body) + if response is not None: + try: + response_payload = response.json() + except Exception: + response_payload = None + if isinstance(response_payload, dict): + payloads.append(response_payload) + parsed_error = _parse_structured_error_payload(error_str) + if parsed_error is not None: + payloads.append(parsed_error) + + saw_validation_signal = False + for payload in payloads: + error_objects = [payload] + nested_error = payload.get("error") + if isinstance(nested_error, dict): + error_objects.append(nested_error) + + for error_object in error_objects: + for key in _STRUCTURED_ERROR_SIGNAL_KEYS: + value = error_object.get(key) + if value is None: + continue + marker = _normalise_structured_marker(value) + if marker in _RATE_LIMIT_STRUCTURED_MARKERS or any( + rate_limit_marker in marker + for rate_limit_marker in _RATE_LIMIT_STRUCTURED_MARKERS + if rate_limit_marker != "429" + ): + return True + if marker in _VALIDATION_STRUCTURED_MARKERS or any( + validation_marker in marker + for validation_marker in _VALIDATION_STRUCTURED_MARKERS + ): + saw_validation_signal = True + + return False if saw_validation_signal else None + + class ExceptionCheckers: """ Helper class for checking various error conditions in exception strings. """ @staticmethod - def is_error_str_rate_limit(error_str: str, status_code: int | None = None) -> bool: + def is_error_str_rate_limit( + error_str: str, + status_code: int | None = None, + *, + response: httpx.Response | None = None, + error_body: Any | None = None, + ) -> bool: """ Check if an error string indicates a rate limit error. @@ -44,6 +168,8 @@ class ExceptionCheckers: bare-number branch: providers echo the request back in validation errors and 429 is an ordinary token id, so an echoed prompt can put a standalone 429 in the body of a 400. The phrase branches stay ungated (#11455). + response: The provider response, when available, for structured error fields. + error_body: The parsed provider error body, when available. Returns: True if the error indicates a rate limit, False otherwise @@ -56,6 +182,14 @@ class ExceptionCheckers: if re.search(r"\b429\b", error_str) and (not isinstance(status_code, int) or status_code == 429): return True + structured_signal = _structured_rate_limit_signal( + error_str=error_str, + response=response, + error_body=error_body, + ) + if structured_signal is not None: + return structured_signal + _error_str_lower: Final = error_str.lower() # Match "rate limit" (including variations like rate-limit / rate_limit) @@ -542,7 +676,10 @@ def _map_anthropic_exception( llm_provider="anthropic", ) elif ExceptionCheckers.is_error_str_rate_limit( - error_str, status_code=getattr(original_exception, "status_code", None) + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), ): raise RateLimitError( message=f"AnthropicException - {error_str}", @@ -902,7 +1039,10 @@ def _map_bedrock_exception( llm_provider="bedrock", ) elif ExceptionCheckers.is_error_str_rate_limit( - error_str, status_code=getattr(original_exception, "status_code", None) + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), ): raise RateLimitError( message=f"BedrockException - {error_str}", @@ -1242,7 +1382,10 @@ def _map_vertex_exception( or "IndexError: list index out of range" in error_str or "429 Unable to submit request because the service is temporarily out of capacity." in error_str or ExceptionCheckers.is_error_str_rate_limit( - error_str, status_code=getattr(original_exception, "status_code", None) + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), ) ): raise RateLimitError( @@ -2041,7 +2184,10 @@ def _map_azure_exception( body=getattr(original_exception, "body", None), ) elif ExceptionCheckers.is_error_str_rate_limit( - error_str, status_code=getattr(original_exception, "status_code", None) + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), ): raise RateLimitError( message=f"AzureException RateLimitError - {message}", 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 10d8815ed50..c0745ed9c80 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 @@ -1107,6 +1107,94 @@ def test_provider_rate_limit_phrases_reach_dispatch_boundary( assert raised.value.llm_provider == provider +@pytest.mark.parametrize( + ("provider", "error_message"), + [ + ( + "gemini", + '{"error": {"code": 400, "status": "RESOURCE_EXHAUSTED", "message": "quota exceeded"}}', + ), + ( + "vertex_ai", + '{"error": {"code": 400, "status": "RESOURCE_EXHAUSTED", "message": "quota exceeded"}}', + ), + ( + "bedrock", + '{"__type": "ProvisionedThroughputExceededException", "message": "provisioned throughput exceeded"}', + ), + ( + "azure", + '{"error": {"code": "429", "type": "rate_limit_error", "message": "too many requests"}}', + ), + ( + "anthropic", + 'AnthropicException - b\'{"type": "error", "error": {"type": "rate_limit_error", "message": "too many requests"}}\'', + ), + ], +) +def test_structured_provider_rate_limit_reaches_dispatch_boundary( + provider, error_message, quiet_exception_mapping +): + """Structured provider throttling signals remain RateLimitError on HTTP 400.""" + with pytest.raises(litellm.RateLimitError) as raised: + exception_type( + model="test-model", + original_exception=_UpstreamErrorWithMessage(error_message, 400), + custom_llm_provider=provider, + ) + + assert raised.value.llm_provider == provider + + +@pytest.mark.parametrize( + ("provider", "error_message", "error_body"), + [ + ( + "gemini", + '{"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Invalid value: too many requests"}}', + None, + ), + ( + "vertex_ai", + '{"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Invalid value: too many requests"}}', + None, + ), + ( + "bedrock", + '{"__type": "ValidationException", "message": "Invalid request: too many requests"}', + {"__type": "ValidationException", "reason": {"echo": "too many requests"}}, + ), + ( + "azure", + '{"error": {"code": "invalid_request_error", "type": "invalid_request_error", "message": "Invalid request: too many requests"}}', + None, + ), + ( + "anthropic", + '{"type": "error", "error": {"type": "invalid_request_error", "message": "Invalid request: too many requests"}}', + None, + ), + ], +) +def test_validation_echo_does_not_reach_rate_limit_boundary( + provider, error_message, error_body, quiet_exception_mapping +): + """A structured 400 validation echo must not become RateLimitError.""" + original_exception = _UpstreamErrorWithMessage(error_message, 400) + if error_body is not None: + original_exception.body = error_body + + with pytest.raises(openai.APIError) as raised: + exception_type( + model="test-model", + original_exception=original_exception, + custom_llm_provider=provider, + ) + + assert not isinstance(raised.value, litellm.RateLimitError) + assert raised.value.status_code == 400 + + @pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER) def test_a_full_context_window_reaches_the_caller_as_the_router_needs_it( provider, quiet_exception_mapping From 2f493e116069510f7728208129ba56eb3ee19ba1 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Sat, 29 Aug 2026 15:39:40 +0200 Subject: [PATCH 06/13] fix(exception_mapping): limit structured guard to validation signals --- .../exception_mapping_utils.py | 51 ++++--------------- .../test_exception_mapping_utils.py | 2 +- 2 files changed, 11 insertions(+), 42 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 2a73ed0808c..d9225487bdb 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -28,27 +28,6 @@ from ..exceptions import ( ) -_RATE_LIMIT_STRUCTURED_MARKERS: Final = frozenset( - { - "429", - "capacity_temporarily_exceeded", - "concurrency_limit_reached", - "concurrent_requests_limit_exceeded", - "insufficient_quota", - "provisioned_throughput_exceeded", - "quota_exceeded", - "rate_limit", - "rate_limit_error", - "request_rate_too_large", - "requests_per_day_exceeded", - "requests_per_minute_exceeded", - "resource_exhausted", - "throttling_exception", - "tokens_per_day_exceeded", - "tokens_per_minute_exceeded", - "too_many_requests", - } -) _VALIDATION_STRUCTURED_MARKERS: Final = frozenset( { "bad_request", @@ -67,7 +46,6 @@ _STRUCTURED_ERROR_SIGNAL_KEYS: Final = ( "type", "error_type", "__type", - "reason", ) @@ -97,13 +75,13 @@ def _parse_structured_error_payload(error_text: str) -> dict[str, Any] | None: return None -def _structured_rate_limit_signal( +def _structured_validation_signal( *, error_str: str, response: httpx.Response | None = None, error_body: Any | None = None, -) -> bool | None: - """Return an explicit rate-limit/validation signal when the provider supplies one.""" +) -> bool: + """Return whether the provider supplies an explicit validation signal.""" payloads: list[dict[str, Any]] = [] if isinstance(error_body, dict): payloads.append(error_body) @@ -118,7 +96,6 @@ def _structured_rate_limit_signal( if parsed_error is not None: payloads.append(parsed_error) - saw_validation_signal = False for payload in payloads: error_objects = [payload] nested_error = payload.get("error") @@ -131,19 +108,12 @@ def _structured_rate_limit_signal( if value is None: continue marker = _normalise_structured_marker(value) - if marker in _RATE_LIMIT_STRUCTURED_MARKERS or any( - rate_limit_marker in marker - for rate_limit_marker in _RATE_LIMIT_STRUCTURED_MARKERS - if rate_limit_marker != "429" - ): + if not marker: + continue + if marker in _VALIDATION_STRUCTURED_MARKERS: return True - if marker in _VALIDATION_STRUCTURED_MARKERS or any( - validation_marker in marker - for validation_marker in _VALIDATION_STRUCTURED_MARKERS - ): - saw_validation_signal = True - return False if saw_validation_signal else None + return False class ExceptionCheckers: @@ -182,13 +152,12 @@ class ExceptionCheckers: if re.search(r"\b429\b", error_str) and (not isinstance(status_code, int) or status_code == 429): return True - structured_signal = _structured_rate_limit_signal( + if _structured_validation_signal( error_str=error_str, response=response, error_body=error_body, - ) - if structured_signal is not None: - return structured_signal + ): + return False _error_str_lower: Final = error_str.lower() 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 c0745ed9c80..0972a494110 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 @@ -1162,7 +1162,7 @@ def test_structured_provider_rate_limit_reaches_dispatch_boundary( ( "bedrock", '{"__type": "ValidationException", "message": "Invalid request: too many requests"}', - {"__type": "ValidationException", "reason": {"echo": "too many requests"}}, + {"__type": "ValidationException", "status": {"echo": "too many requests"}}, ), ( "azure", From a246581c966c2285fc7ac14665c347317500a280 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Sun, 30 Aug 2026 00:17:50 +0200 Subject: [PATCH 07/13] style: sort exception mapping imports --- litellm/litellm_core_utils/exception_mapping_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index d9225487bdb..9b685394729 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -27,7 +27,6 @@ from ..exceptions import ( UnprocessableEntityError, ) - _VALIDATION_STRUCTURED_MARKERS: Final = frozenset( { "bad_request", From 85f8d2b97f2a03a85b50cd8a3ea13f93d60c8bf3 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Sun, 30 Aug 2026 05:34:31 +0200 Subject: [PATCH 08/13] fix: satisfy strict exception mapping lint --- litellm/litellm_core_utils/exception_mapping_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 9b685394729..a492d68f25c 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -48,7 +48,7 @@ _STRUCTURED_ERROR_SIGNAL_KEYS: Final = ( ) -def _normalise_structured_marker(value: Any) -> str: +def _normalise_structured_marker(value: object) -> str: """Normalize provider code/type values for marker comparison.""" if not isinstance(value, (int, str)): return "" @@ -78,7 +78,7 @@ def _structured_validation_signal( *, error_str: str, response: httpx.Response | None = None, - error_body: Any | None = None, + error_body: object | None = None, ) -> bool: """Return whether the provider supplies an explicit validation signal.""" payloads: list[dict[str, Any]] = [] @@ -87,7 +87,7 @@ def _structured_validation_signal( if response is not None: try: response_payload = response.json() - except Exception: + except (TypeError, ValueError): response_payload = None if isinstance(response_payload, dict): payloads.append(response_payload) @@ -126,7 +126,7 @@ class ExceptionCheckers: status_code: int | None = None, *, response: httpx.Response | None = None, - error_body: Any | None = None, + error_body: object | None = None, ) -> bool: """ Check if an error string indicates a rate limit error. From ee37a3b0ab09adb3e06f64defb48a6e78c3deabe Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Mon, 31 Aug 2026 09:14:46 +0200 Subject: [PATCH 09/13] fix(exception): use immutable validation payload collections --- .../exception_mapping_utils.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index a492d68f25c..6f404f734af 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -58,11 +58,9 @@ def _normalise_structured_marker(value: object) -> str: def _parse_structured_error_payload(error_text: str) -> dict[str, Any] | None: """Parse a JSON error body, including SDK prefixes around the JSON.""" - candidates = [error_text.strip()] - start = error_text.find("{") - end = error_text.rfind("}") - if start >= 0 and end > start: - candidates.append(error_text[start : end + 1]) + start: Final = error_text.find("{") + end: Final = error_text.rfind("}") + candidates: Final = (error_text.strip(),) + ((error_text[start : end + 1],) if start >= 0 and end > start else ()) for candidate in candidates: try: @@ -74,6 +72,15 @@ def _parse_structured_error_payload(error_text: str) -> dict[str, Any] | None: return None +def _response_json_payload(response: httpx.Response | None) -> object | None: + if response is None: + return None + try: + return response.json() + except (TypeError, ValueError): + return None + + def _structured_validation_signal( *, error_str: str, @@ -81,25 +88,19 @@ def _structured_validation_signal( error_body: object | None = None, ) -> bool: """Return whether the provider supplies an explicit validation signal.""" - payloads: list[dict[str, Any]] = [] - if isinstance(error_body, dict): - payloads.append(error_body) - if response is not None: - try: - response_payload = response.json() - except (TypeError, ValueError): - response_payload = None - if isinstance(response_payload, dict): - payloads.append(response_payload) - parsed_error = _parse_structured_error_payload(error_str) - if parsed_error is not None: - payloads.append(parsed_error) + payloads: Final = tuple( + payload + for payload in ( + error_body, + _response_json_payload(response), + _parse_structured_error_payload(error_str), + ) + if isinstance(payload, dict) + ) for payload in payloads: - error_objects = [payload] - nested_error = payload.get("error") - if isinstance(nested_error, dict): - error_objects.append(nested_error) + nested_error: Final = payload.get("error") + error_objects: Final = (payload, nested_error) if isinstance(nested_error, dict) else (payload,) for error_object in error_objects: for key in _STRUCTURED_ERROR_SIGNAL_KEYS: From 9c6262513020f71a94a28f6425414eb4a5e770c8 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Mon, 31 Aug 2026 10:07:59 +0200 Subject: [PATCH 10/13] fix(exception): avoid final bindings in payload loop --- litellm/litellm_core_utils/exception_mapping_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 6f404f734af..1625aa5700b 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -99,8 +99,8 @@ def _structured_validation_signal( ) for payload in payloads: - nested_error: Final = payload.get("error") - error_objects: Final = (payload, nested_error) if isinstance(nested_error, dict) else (payload,) + nested_error = payload.get("error") + error_objects = (payload, nested_error) if isinstance(nested_error, dict) else (payload,) for error_object in error_objects: for key in _STRUCTURED_ERROR_SIGNAL_KEYS: From 746ca296f3d148dab5c1472ad989d2e320a4634b Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Wed, 2 Sep 2026 12:32:03 +0200 Subject: [PATCH 11/13] fix(exception_mapping): guard validation echoes across mappers --- .../exception_mapping_utils.py | 37 +++++++++++++------ .../test_exception_mapping_utils.py | 35 +++++++++++++++++- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 1625aa5700b..6729762c7cc 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -33,6 +33,7 @@ _VALIDATION_STRUCTURED_MARKERS: Final = frozenset( "invalid_argument", "invalid_request", "invalid_request_error", + "validation", "validation_error", "validation_exception", } @@ -137,7 +138,8 @@ class ExceptionCheckers: status_code: The HTTP status the provider returned, when known. Gates only the bare-number branch: providers echo the request back in validation errors and 429 is an ordinary token id, so an echoed prompt can put a standalone 429 in - the body of a 400. The phrase branches stay ungated (#11455). + the body of a 400. Phrase branches stay ungated (#11455) when no structured + validation signal is present. response: The provider response, when available, for structured error fields. error_body: The parsed provider error body, when available. @@ -406,7 +408,10 @@ def _map_openai_exception( exception_provider = custom_llm_provider[0].upper() + custom_llm_provider[1:] + "Exception" if ExceptionCheckers.is_error_str_rate_limit( - error_str, status_code=getattr(original_exception, "status_code", None) + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), ): raise RateLimitError( message=f"RateLimitError: {exception_provider} - {message}", @@ -1345,17 +1350,24 @@ def _map_vertex_exception( ), ) elif ( - "429 Quota exceeded" in error_str - or "Quota exceeded for" in error_str - or "Resource exhausted" in error_str - or "IndexError: list index out of range" in error_str - or "429 Unable to submit request because the service is temporarily out of capacity." in error_str - or ExceptionCheckers.is_error_str_rate_limit( - error_str, - status_code=getattr(original_exception, "status_code", None), + not _structured_validation_signal( + error_str=error_str, response=getattr(original_exception, "response", None), error_body=getattr(original_exception, "body", None), ) + and ( + "429 Quota exceeded" in error_str + or "Quota exceeded for" in error_str + or "Resource exhausted" in error_str + or "IndexError: list index out of range" in error_str + or "429 Unable to submit request because the service is temporarily out of capacity." in error_str + or ExceptionCheckers.is_error_str_rate_limit( + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), + ) + ) ): raise RateLimitError( message=f"litellm.RateLimitError: {custom_llm_provider}Exception - {error_str}", @@ -1878,7 +1890,10 @@ def _map_together_ai_exception( llm_provider="together_ai", ) elif ExceptionCheckers.is_error_str_rate_limit( - error_str, status_code=getattr(original_exception, "status_code", None) + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), ): raise RateLimitError( message=f"TogetherAIException - {error_str}", 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 0972a494110..5b2102f290c 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 @@ -166,6 +166,24 @@ class TestExceptionCheckers: is True ) + @pytest.mark.parametrize("response_text", ["not-json", '["invalid_request_error"]']) + def test_rate_limit_classifier_falls_back_when_response_is_not_an_object(self, response_text): + """Unusable response JSON must not disable the legacy phrase fallback.""" + + response = httpx.Response( + status_code=400, + request=httpx.Request("POST", "https://example.invalid"), + text=response_text, + ) + assert ( + ExceptionCheckers.is_error_str_rate_limit( + "Provider validation failed: too many requests", + status_code=400, + response=response, + ) + is True + ) + def test_rate_limit_provider_specific_phrases(self): """Test that common vendor rate-limit and quota exhaustion phrases are properly recognized.""" vendor_phrases: Final = ( @@ -1156,7 +1174,7 @@ def test_structured_provider_rate_limit_reaches_dispatch_boundary( ), ( "vertex_ai", - '{"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Invalid value: too many requests"}}', + '{"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Invalid request: Quota exceeded for the echoed field"}}', None, ), ( @@ -1174,6 +1192,21 @@ def test_structured_provider_rate_limit_reaches_dispatch_boundary( '{"type": "error", "error": {"type": "invalid_request_error", "message": "Invalid request: too many requests"}}', None, ), + ( + "openai", + "{'error': {'type': 'invalid_request_error', 'message': 'Invalid request: too many requests'}}", + { + "error": { + "type": "invalid_request_error", + "message": "Invalid request: too many requests", + } + }, + ), + ( + "together_ai", + "{'error': {'message': 'Invalid request: too many requests'}, 'error_type': 'validation'}", + {"error_type": "validation", "error": "Invalid request: too many requests"}, + ), ], ) def test_validation_echo_does_not_reach_rate_limit_boundary( From 44521dcfdd2c4f6a88d805e194c679b1c81a1574 Mon Sep 17 00:00:00 2001 From: dlowzzxx Date: Wed, 2 Sep 2026 13:39:10 +0200 Subject: [PATCH 12/13] style: format exception mapping utility --- .../exception_mapping_utils.py | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 6729762c7cc..11b369de3b6 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -1349,25 +1349,22 @@ def _map_vertex_exception( ), ), ) - elif ( - not _structured_validation_signal( - error_str=error_str, + elif not _structured_validation_signal( + error_str=error_str, + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), + ) and ( + "429 Quota exceeded" in error_str + or "Quota exceeded for" in error_str + or "Resource exhausted" in error_str + or "IndexError: list index out of range" in error_str + or "429 Unable to submit request because the service is temporarily out of capacity." in error_str + or ExceptionCheckers.is_error_str_rate_limit( + error_str, + status_code=getattr(original_exception, "status_code", None), response=getattr(original_exception, "response", None), error_body=getattr(original_exception, "body", None), ) - and ( - "429 Quota exceeded" in error_str - or "Quota exceeded for" in error_str - or "Resource exhausted" in error_str - or "IndexError: list index out of range" in error_str - or "429 Unable to submit request because the service is temporarily out of capacity." in error_str - or ExceptionCheckers.is_error_str_rate_limit( - error_str, - status_code=getattr(original_exception, "status_code", None), - response=getattr(original_exception, "response", None), - error_body=getattr(original_exception, "body", None), - ) - ) ): raise RateLimitError( message=f"litellm.RateLimitError: {custom_llm_provider}Exception - {error_str}", From 49cea41cdf1e02123165e4e6e7678f9ad234742a Mon Sep 17 00:00:00 2001 From: dlowzzxx <210235699+dlowzzxx@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:04:14 +0200 Subject: [PATCH 13/13] fix(exception_mapping): preserve streamed errors and explicit rate limits Assisted-by: OpenAI Codex --- .../exception_mapping_utils.py | 114 ++++++++++-------- .../test_exception_mapping_utils.py | 11 ++ 2 files changed, 74 insertions(+), 51 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index cd56572f604..8b2b6accc1e 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -57,6 +57,7 @@ _STRUCTURED_ERROR_SIGNAL_KEYS: Final = ( "error_type", "__type", ) +_ERROR_PAYLOAD_ADAPTER: Final = TypeAdapter(Mapping[str, object]) def _normalise_structured_marker(value: object) -> str: @@ -75,23 +76,42 @@ def _parse_structured_error_payload(error_text: str) -> Mapping[str, object] | N for candidate in candidates: try: - payload: Final = json.loads(candidate) - except (TypeError, ValueError): + return _ERROR_PAYLOAD_ADAPTER.validate_json(candidate) + except ValidationError: continue - if isinstance(payload, dict): - return payload return None -def _response_json_payload(response: httpx.Response | None) -> object | None: +def _response_json_payload(response: httpx.Response | None) -> Mapping[str, object] | None: if response is None: return None try: - return response.json() - except (TypeError, ValueError): + return _ERROR_PAYLOAD_ADAPTER.validate_json(response.content) + except (ValidationError, httpx.ResponseNotRead): return None +def _error_payload_mapping(payload: object) -> Mapping[str, object] | None: + try: + return _ERROR_PAYLOAD_ADAPTER.validate_python(payload) + except ValidationError: + return None + + +def _error_object_has_validation_marker(error_object: Mapping[str, object]) -> bool: + return any( + _normalise_structured_marker(error_object.get(key)) in _VALIDATION_STRUCTURED_MARKERS + for key in _STRUCTURED_ERROR_SIGNAL_KEYS + ) + + +def _payload_has_validation_marker(payload: Mapping[str, object]) -> bool: + nested_error: Final = _error_payload_mapping(payload.get("error")) + return _error_object_has_validation_marker(payload) or ( + nested_error is not None and _error_object_has_validation_marker(nested_error) + ) + + def _structured_validation_signal( *, error_str: str, @@ -102,29 +122,14 @@ def _structured_validation_signal( payloads: Final = tuple( payload for payload in ( - error_body, + _error_payload_mapping(error_body), _response_json_payload(response), _parse_structured_error_payload(error_str), ) - if isinstance(payload, dict) + if payload is not None ) - for payload in payloads: - nested_error: Final = payload.get("error") - error_objects: Final = (payload, nested_error) if isinstance(nested_error, dict) else (payload,) - - for error_object in error_objects: - for key in _STRUCTURED_ERROR_SIGNAL_KEYS: - value: Final = error_object.get(key) - if value is None: - continue - marker: Final = _normalise_structured_marker(value) - if not marker: - continue - if marker in _VALIDATION_STRUCTURED_MARKERS: - return True - - return False + return any(_payload_has_validation_marker(payload) for payload in payloads) class ExceptionCheckers: @@ -685,8 +690,8 @@ def _map_anthropic_exception( extra_information: str, ) -> None: error_payload: Final = _parse_structured_error_payload(error_str) - structured_error: Final = error_payload.get("error") if error_payload is not None else None - structured_message: Final = structured_error.get("message") if isinstance(structured_error, Mapping) else None + structured_error: Final = _error_payload_mapping(error_payload.get("error")) if error_payload is not None else None + structured_message: Final = structured_error.get("message") if structured_error is not None else None if ( "prompt is too long" in error_str or "prompt: length" in error_str @@ -703,14 +708,17 @@ def _map_anthropic_exception( model=model, llm_provider="anthropic", ) - elif ( - isinstance(structured_message, str) - and structured_message.startswith("You have reached your specified API usage limits.") - ) or ExceptionCheckers.is_error_str_rate_limit( - error_str, - status_code=getattr(original_exception, "status_code", None), - response=getattr(original_exception, "response", None), - error_body=getattr(original_exception, "body", None), + elif getattr(original_exception, "status_code", None) != 429 and ( + ( + isinstance(structured_message, str) + and structured_message.startswith("You have reached your specified API usage limits.") + ) + or ExceptionCheckers.is_error_str_rate_limit( + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), + ) ): raise RateLimitError( message=f"AnthropicException - {error_str}", @@ -1087,7 +1095,7 @@ def _map_bedrock_exception( llm_provider="bedrock", response=getattr(original_exception, "response", None), ) - elif ExceptionCheckers.is_error_str_rate_limit( + elif getattr(original_exception, "status_code", None) != 429 and ExceptionCheckers.is_error_str_rate_limit( error_str, status_code=getattr(original_exception, "status_code", None), response=getattr(original_exception, "response", None), @@ -1425,22 +1433,26 @@ def _map_vertex_exception( ), ), ) - elif not _structured_validation_signal( - error_str=error_str, - response=getattr(original_exception, "response", None), - error_body=getattr(original_exception, "body", None), - ) and ( - "429 Quota exceeded" in error_str - or "Quota exceeded for" in error_str - or "Resource exhausted" in error_str - or "IndexError: list index out of range" in error_str - or "429 Unable to submit request because the service is temporarily out of capacity." in error_str - or ExceptionCheckers.is_error_str_rate_limit( - error_str, - status_code=getattr(original_exception, "status_code", None), + elif ( + getattr(original_exception, "status_code", None) != 429 + and not _structured_validation_signal( + error_str=error_str, response=getattr(original_exception, "response", None), error_body=getattr(original_exception, "body", None), ) + and ( + "429 Quota exceeded" in error_str + or "Quota exceeded for" in error_str + or "Resource exhausted" in error_str + or "IndexError: list index out of range" in error_str + or "429 Unable to submit request because the service is temporarily out of capacity." in error_str + or ExceptionCheckers.is_error_str_rate_limit( + error_str, + status_code=getattr(original_exception, "status_code", None), + response=getattr(original_exception, "response", None), + error_body=getattr(original_exception, "body", None), + ) + ) ): raise RateLimitError( message=f"litellm.RateLimitError: {custom_llm_provider}Exception - {error_str}", @@ -1962,7 +1974,7 @@ def _map_together_ai_exception( model=model, llm_provider="together_ai", ) - elif ExceptionCheckers.is_error_str_rate_limit( + elif getattr(original_exception, "status_code", None) != 429 and ExceptionCheckers.is_error_str_rate_limit( error_str, status_code=getattr(original_exception, "status_code", None), response=getattr(original_exception, "response", None), @@ -2240,7 +2252,7 @@ def _map_azure_exception( response=getattr(original_exception, "response", None), body=getattr(original_exception, "body", None), ) - elif ExceptionCheckers.is_error_str_rate_limit( + elif getattr(original_exception, "status_code", None) != 429 and ExceptionCheckers.is_error_str_rate_limit( error_str, status_code=getattr(original_exception, "status_code", None), response=getattr(original_exception, "response", None), 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 391af910436..b717224213a 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -224,6 +224,17 @@ class TestExceptionCheckers: is True ) + def test_rate_limit_classifier_leaves_unread_stream_available(self): + response: Final = httpx.Response( + status_code=400, + stream=httpx.ByteStream(b'{"error":{"type":"invalid_request_error"}}'), + ) + assert ( + ExceptionCheckers.is_error_str_rate_limit("too many requests", status_code=400, response=response) is True + ) + assert response.is_stream_consumed is False + assert response.read() == b'{"error":{"type":"invalid_request_error"}}' + def test_rate_limit_provider_specific_phrases(self): """Test that common vendor rate-limit and quota exhaustion phrases are properly recognized.""" vendor_phrases: Final = (