From a4049b730cfdc1047452e466edc2528b08d62e50 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 21:00:24 -0700 Subject: [PATCH] fix(exceptions): keep a refused connection an APIConnectionError #38318 taught exception_type to map upstream status codes for providers with no branch of their own. It reads the status code off the exception, but _handle_error stamps 500 onto every failure that never carried one, so a refused connection reached the mapper wearing a status code nothing upstream had sent, and came back as InternalServerError instead of APIConnectionError. The two are not interchangeable to a caller: a 5xx says the provider answered and failed, which the router treats as a reason to cool the deployment down, while a connection error says the request never landed. BaseLLMException now records whether its status code was received or synthesized, _handle_error sets that when it invents the 500, and the status mapper declines to act on a code litellm made up, so those failures fall through to the APIConnectionError the branch was always meant to produce. Genuine upstream 5xx responses are untouched, which the second test pins. The search transformation assertion #38318 had loosened to InternalServerError goes back to APIConnectionError for the same reason. --- .../exception_mapping_utils.py | 2 + litellm/llms/base_llm/chat/transformation.py | 2 + litellm/llms/custom_httpx/llm_http_handler.py | 12 +++-- .../test_exception_mapping_utils.py | 49 +++++++++++++++++++ .../search/test_base_search_transformation.py | 2 +- 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 70374f87b99..8f8c955d971 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2222,6 +2222,8 @@ def _map_exception_by_status( status_code: Final = original_exception.status_code if hasattr(original_exception, "status_code") else None if not isinstance(status_code, int) or status_code < 400: return + if getattr(original_exception, "status_code_is_synthesized", False): + return message: Final = f"{exception_provider} - {error_str}" response: Final = original_exception.response if hasattr(original_exception, "response") else None match status_code: diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index d147063df73..11c763ceb9a 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -46,8 +46,10 @@ class BaseLLMException(Exception): request: httpx.Request | None = None, response: httpx.Response | None = None, body: dict | None = None, + status_code_is_synthesized: bool = False, ): self.status_code = status_code + self.status_code_is_synthesized = status_code_is_synthesized self.message: str = message self.headers = headers if request: diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index df5365017ac..417ecd80be2 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5947,11 +5947,13 @@ class BaseLLMHTTPHandler: BaseEvalsAPIConfig, ], ): - status_code = getattr(e, "status_code", 500) + received_status_code: Final = ( + e.response.status_code if isinstance(e, httpx.HTTPStatusError) else getattr(e, "status_code", None) + ) + status_code = received_status_code if isinstance(received_status_code, int) else 500 error_headers = getattr(e, "headers", None) if isinstance(e, httpx.HTTPStatusError): error_text = e.response.text - status_code = e.response.status_code else: error_text = getattr(e, "text", str(e)) error_response: Final = getattr(e, "response", None) @@ -5971,13 +5973,17 @@ class BaseLLMHTTPHandler: status_code=status_code, message=error_text, headers=error_headers, + status_code_is_synthesized=not isinstance(received_status_code, int), ) - raise provider_config.get_error_class( + provider_error: Final = provider_config.get_error_class( error_message=error_text, status_code=status_code, headers=error_headers, ) + if not isinstance(received_status_code, int): + provider_error.status_code_is_synthesized = True + raise provider_error @staticmethod def _append_query_params(url: str, query_params: RealtimeQueryParams | None) -> 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 8e89180a9e4..15b7ae9d07a 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 @@ -1163,3 +1163,52 @@ def test_bedrock_mantle_context_overflow_maps_to_context_window_exceeded(): assert excinfo.value.status_code == 400 assert "prompt is too long: 1055489 tokens > 1050000 maximum" in excinfo.value.message + + +def test_branchless_provider_transport_error_maps_to_api_connection_error(): + from litellm.llms.base_llm.chat.transformation import BaseLLMException + + original_exception = BaseLLMException(status_code=500, message="[Errno 111] Connection refused") + original_exception.status_code_is_synthesized = True + + with pytest.raises(litellm.APIConnectionError): + exception_type( + model="test-agent", + original_exception=original_exception, + custom_llm_provider="a2a", + ) + + +def test_branchless_provider_upstream_500_still_maps_to_internal_server_error(): + from litellm.llms.base_llm.chat.transformation import BaseLLMException + + original_exception = BaseLLMException(status_code=500, message="upstream exploded") + + with pytest.raises(litellm.InternalServerError): + exception_type( + model="test-agent", + original_exception=original_exception, + custom_llm_provider="a2a", + ) + + +def test_handle_error_marks_only_a_status_code_it_never_received(): + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + + handler = BaseLLMHTTPHandler() + + with pytest.raises(litellm.llms.base_llm.chat.transformation.BaseLLMException) as transport: + raise handler._handle_error(e=httpx.ConnectError("Connection refused"), provider_config=None) + assert transport.value.status_code == 500 + assert transport.value.status_code_is_synthesized is True + + request = httpx.Request(method="POST", url="https://example.invalid") + upstream = httpx.HTTPStatusError( + "server error", + request=request, + response=httpx.Response(status_code=500, request=request, text="upstream exploded"), + ) + with pytest.raises(litellm.llms.base_llm.chat.transformation.BaseLLMException) as received: + raise handler._handle_error(e=upstream, provider_config=None) + assert received.value.status_code == 500 + assert received.value.status_code_is_synthesized is False diff --git a/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py b/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py index 35a54332f66..e6aad7688d1 100644 --- a/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py +++ b/tests/test_litellm/llms/base_llm/search/test_base_search_transformation.py @@ -322,7 +322,7 @@ async def test_query_param_key_not_leaked_with_dummy_caller_key( "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", fake_get, ): - with pytest.raises(litellm.InternalServerError): + with pytest.raises(litellm.APIConnectionError): await litellm.asearch( query="secrets", search_provider=provider,