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.
This commit is contained in:
Oliver Yu 2026-10-20 01:47:27 +00:00
parent 923b7d1784
commit 1165261baf
2 changed files with 50 additions and 2 deletions

View file

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

View file

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