From 1165261bafafa7bacd648d4ee737d1ffa9da8884 Mon Sep 17 00:00:00 2001 From: Oliver Yu Date: Tue, 20 Oct 2026 01:47:27 +0000 Subject: [PATCH 1/2] fix(http_handler): keep the connect timeout bounded when a request passes a numeric timeout AsyncHTTPHandler.post passed a float per-request timeout straight to build_request, so request_timeout: 600 also became a 600 s connect timeout and replaced the client's 5 s connect timeout. With the aiohttp transport a connect to a stopped upstream's cached IP then hung 40-137 s before the router could fail over. Numeric timeouts now keep connect=min(timeout, HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS), for the request and for the fresh client that retries it after a ConnectError. An explicit httpx.Timeout is passed through unchanged. --- litellm/llms/custom_httpx/http_handler.py | 10 ++++- .../llms/custom_httpx/test_http_handler.py | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index cffeb44f12c..39582b7885a 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -180,6 +180,12 @@ def _default_cached_client_timeout() -> httpx.Timeout: return httpx.Timeout(timeout=configured, connect=HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS) +def _with_bounded_connect(timeout: float | httpx.Timeout | None) -> httpx.Timeout | None: + if timeout is None or isinstance(timeout, httpx.Timeout): + return timeout + return httpx.Timeout(timeout=timeout, connect=min(timeout, HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS)) + + _CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER: Final = 2 @@ -843,7 +849,7 @@ class AsyncHTTPHandler: json=json, params=params, headers=headers, - timeout=timeout, + timeout=_with_bounded_connect(timeout), files=files, content=request_content, ) @@ -854,7 +860,7 @@ class AsyncHTTPHandler: return response except httpx.ConnectError: # Retry the request with a new session if there is a connection error - new_client: Final = self.create_client(timeout=timeout, event_hooks=self.event_hooks) + new_client: Final = self.create_client(timeout=_with_bounded_connect(timeout), event_hooks=self.event_hooks) try: return await self.single_connection_request( url=url, diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index bd4ed4c99cc..c509b6a3fdc 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -19,6 +19,7 @@ from aiohttp import ClientSession, TCPConnector import litellm from litellm.caching.llm_caching_handler import LLMClientCache +from litellm.constants import HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport from litellm.llms.custom_httpx.http_handler import ( _CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER, @@ -135,6 +136,47 @@ def test_sync_post_streaming_status_error_should_not_wait_forever_for_body( litellm_handler.close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("timeout", "expected"), + [ + (600.0, {"connect": HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS, "read": 600.0, "write": 600.0, "pool": 600.0}), + (2.0, {"connect": 2.0, "read": 2.0, "write": 2.0, "pool": 2.0}), + (httpx.Timeout(600.0), {"connect": 600.0, "read": 600.0, "write": 600.0, "pool": 600.0}), + ], +) +async def test_async_post_numeric_timeout_keeps_bounded_connect_timeout( + timeout: float | httpx.Timeout, expected: Mapping[str, float] +): + seen: Final[list[Mapping[str, float]]] = [] + + def capture(request: httpx.Request) -> httpx.Response: + seen.append(request.extensions["timeout"]) + return httpx.Response(200, json={}) + + handler: Final = AsyncHTTPHandler(transport=httpx.MockTransport(capture)) + await handler.post("http://upstream.invalid/v1/chat/completions", json={}, timeout=timeout) + + assert seen == [expected] + + +@pytest.mark.asyncio +async def test_async_post_replay_after_connect_error_keeps_bounded_connect_timeout(): + seen: Final[list[Mapping[str, float]]] = [] + + def refuse_then_capture(request: httpx.Request) -> httpx.Response: + if not seen: + seen.append({}) + raise httpx.ConnectError("connection refused", request=request) + seen.append(request.extensions["timeout"]) + return httpx.Response(200, json={}) + + handler: Final = AsyncHTTPHandler(transport=httpx.MockTransport(refuse_then_capture)) + await handler.post("http://upstream.invalid/v1/chat/completions", json={}, timeout=600.0) + + assert seen[1]["connect"] == HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS + + @pytest.mark.asyncio async def test_ssl_security_level(monkeypatch): # Ensure aiohttp transport is enabled for this test From 122701f2249aeed07eb5db476052844f4b26052a Mon Sep 17 00:00:00 2001 From: Oliver Yu Date: Tue, 20 Oct 2026 04:40:49 +0000 Subject: [PATCH 2/2] test(a2a): expect the bounded connect timeout in the pydantic_ai provider tests PydanticAIProviderConfig sends its float timeout through AsyncHTTPHandler.post, so connect is now min(timeout, HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS) while read, write and pool keep the caller's value. --- .../test_pydantic_ai_agent_headers.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/unit/a2a_protocol/providers/pydantic_ai_agents/test_pydantic_ai_agent_headers.py b/tests/unit/a2a_protocol/providers/pydantic_ai_agents/test_pydantic_ai_agent_headers.py index 8d5e15b4b71..c11b069282c 100644 --- a/tests/unit/a2a_protocol/providers/pydantic_ai_agents/test_pydantic_ai_agent_headers.py +++ b/tests/unit/a2a_protocol/providers/pydantic_ai_agents/test_pydantic_ai_agent_headers.py @@ -11,6 +11,7 @@ import respx import litellm from litellm.a2a_protocol.providers.pydantic_ai_agents.config import PydanticAIProviderConfig +from litellm.constants import HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS from litellm.a2a_protocol.providers.pydantic_ai_agents.transformation import ( PydanticAITransformation, ) @@ -240,7 +241,12 @@ async def test_provider_config_non_streaming_defaults_to_sixty_second_timeout(ag "id": "req-5", "result": {"kind": "message", "role": "agent", "parts": [{"kind": "text", "text": "ok"}], "messageId": ANY}, } - assert request.extensions["timeout"] == {"connect": 60.0, "read": 60.0, "write": 60.0, "pool": 60.0} + assert request.extensions["timeout"] == { + "connect": min(60.0, HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS), + "read": 60.0, + "write": 60.0, + "pool": 60.0, + } assert request.headers["content-type"] == "application/json" @@ -259,7 +265,12 @@ async def test_provider_config_non_streaming_forwards_timeout_and_ignores_unrela request: Final = agent_route.calls.last.request assert response["id"] == "req-5" - assert request.extensions["timeout"] == {"connect": 5.5, "read": 5.5, "write": 5.5, "pool": 5.5} + assert request.extensions["timeout"] == { + "connect": min(5.5, HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS), + "read": 5.5, + "write": 5.5, + "pool": 5.5, + } assert request.headers["x-trace-id"] == "abc-123"