From 8ca744036aa7ed18b4e61caecb98ae1213698f1a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 12 Mar 2026 23:01:25 -0700 Subject: [PATCH] [Fix] Malformed messages returning 500 instead of 400 The existing AttributeError detection in proxy error handling only checked one level deep in the exception chain (__cause__, __context__, original_exception). In practice, the AttributeError from malformed messages gets wrapped in multiple layers (AttributeError -> OpenAIException -> APIConnectionError), so the check never found it. Extracted the check into _has_attribute_error_in_chain() which walks the full exception chain recursively (depth-capped at 10 to prevent infinite loops from circular references). Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/common_request_processing.py | 41 +++++++++------- .../proxy/test_common_request_processing.py | 48 +++++++++++++++++++ 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 41fa437962..7601af8f34 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -384,6 +384,25 @@ def _get_cost_breakdown_from_logging_obj( return original_cost, discount_amount, margin_total_amount, margin_percent +def _has_attribute_error_in_chain(exc: Exception, _depth: int = 0) -> bool: + """Walk the exception chain to find an AttributeError at any depth. + + Checks __cause__, __context__, and the litellm-specific original_exception + attribute recursively. Depth is capped to avoid infinite loops from + circular exception references. + """ + if _depth > 10: + return False + if isinstance(exc, AttributeError): + return True + for attr in ("__cause__", "__context__", "original_exception"): + inner = getattr(exc, attr, None) + if inner is not None and isinstance(inner, BaseException): + if _has_attribute_error_in_chain(inner, _depth + 1): + return True + return False + + class ProxyBaseLLMRequestProcessing: def __init__(self, data: dict): self.data = data @@ -1281,23 +1300,11 @@ class ProxyBaseLLMRequestProcessing: detail={"error": error_text}, ) error_msg = f"{str(e)}" - # Check for AttributeError in various places: - # 1. Direct AttributeError (already handled above) - # 2. In underlying exception (__cause__, __context__, original_exception) - has_attribute_error = ( - ( - isinstance(e, Exception) - and isinstance(getattr(e, "__cause__", None), AttributeError) - ) - or ( - isinstance(e, Exception) - and isinstance(getattr(e, "__context__", None), AttributeError) - ) - or ( - isinstance(e, Exception) - and isinstance(getattr(e, "original_exception", None), AttributeError) - ) - ) + # Check for AttributeError in the exception chain. + # The AttributeError may be wrapped in multiple layers + # (e.g. AttributeError -> OpenAIException -> APIConnectionError), + # so walk __cause__, __context__, and original_exception recursively. + has_attribute_error = _has_attribute_error_in_chain(e) if has_attribute_error: raise ProxyException( diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 3869a24d35..223f0b335f 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -15,6 +15,7 @@ from litellm.proxy.common_request_processing import ( ProxyConfig, _extract_error_from_sse_chunk, _get_cost_breakdown_from_logging_obj, + _has_attribute_error_in_chain, _is_azure_model_router_request, _override_openai_response_model, _parse_event_data_for_error, @@ -1701,3 +1702,50 @@ class TestDDSpanTaggerTagRequest: ) mock_set_tag.assert_called_once_with("litellm.requested_model", "claude-3-5-sonnet") + + +class TestHasAttributeErrorInChain: + """Tests for _has_attribute_error_in_chain helper.""" + + def test_direct_attribute_error(self): + exc = AttributeError("'str' object has no attribute 'get'") + assert _has_attribute_error_in_chain(exc) is True + + def test_no_attribute_error(self): + exc = ValueError("some other error") + assert _has_attribute_error_in_chain(exc) is False + + def test_attribute_error_in_cause(self): + inner = AttributeError("bad attribute") + outer = RuntimeError("wrapper") + outer.__cause__ = inner + assert _has_attribute_error_in_chain(outer) is True + + def test_attribute_error_in_context(self): + inner = AttributeError("bad attribute") + outer = RuntimeError("wrapper") + outer.__context__ = inner + assert _has_attribute_error_in_chain(outer) is True + + def test_attribute_error_in_original_exception(self): + inner = AttributeError("bad attribute") + outer = RuntimeError("wrapper") + outer.original_exception = inner # type: ignore + assert _has_attribute_error_in_chain(outer) is True + + def test_attribute_error_nested_two_levels(self): + """Simulates the real failure: AttributeError -> OpenAIException -> APIConnectionError.""" + attr_err = AttributeError("'str' object has no attribute 'get'") + mid = Exception("OpenAIException wrapper") + mid.__context__ = attr_err + outer = Exception("APIConnectionError wrapper") + outer.__context__ = mid + assert _has_attribute_error_in_chain(outer) is True + + def test_depth_limit_prevents_infinite_loop(self): + """Ensure circular references don't cause infinite recursion.""" + exc_a = RuntimeError("a") + exc_b = RuntimeError("b") + exc_a.__context__ = exc_b + exc_b.__context__ = exc_a # circular + assert _has_attribute_error_in_chain(exc_a) is False