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.
This commit is contained in:
Yuneng Jiang 2026-08-27 21:00:24 -07:00
parent 3300fc3a96
commit a4049b730c
No known key found for this signature in database
5 changed files with 63 additions and 4 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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,