fix(custom_httpx): resolve effective timeout instead of bypassing client defaults with None

AsyncHTTPHandler verb methods passed timeout=None straight to httpx when the
handler was built without one; httpx treats an explicit None as "no timeouts",
bypassing the client default the handler was created with, and Timeout errors
rendered the None literally ("Connection timed out after None seconds.").
Sync HTTPHandler verb methods already let the client default apply but printed
the same None in their error message.

- add _resolve_effective_timeout() mapping None to _DEFAULT_TIMEOUT
- async post/put/patch/delete now send the resolved timeout to httpx
- sync post/patch/put/delete error messages report the effective timeout
- explicit per-call timeouts are untouched and still win

Fixes #14635
This commit is contained in:
Sisyphus 2026-08-27 23:27:24 +08:00
parent 75736323e6
commit 2f02e33db5
2 changed files with 83 additions and 8 deletions

View file

@ -163,6 +163,17 @@ def _default_cached_client_timeout() -> httpx.Timeout:
return httpx.Timeout(timeout=configured, connect=HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS)
def _resolve_effective_timeout(timeout: float | httpx.Timeout | None) -> float | httpx.Timeout:
"""Resolve the timeout actually in effect for a request.
`None` means "use the client default", which `create_client` maps to
`_DEFAULT_TIMEOUT`. An explicit `timeout=None` handed to httpx bypasses the
client default entirely (no timeouts are enforced), so `None` must be
resolved before building a request or rendering a Timeout error message.
"""
return timeout if timeout is not None else _DEFAULT_TIMEOUT
_CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER: Final = 2
@ -687,7 +698,7 @@ class AsyncHTTPHandler:
start_time: Final = time.time()
try:
if timeout is None:
timeout = self.timeout
timeout = _resolve_effective_timeout(self.timeout)
# Prepare data/content parameters to prevent httpx DeprecationWarning (memory leak fix)
request_data, request_content = _prepare_request_data_and_content(data, content)
@ -754,7 +765,7 @@ class AsyncHTTPHandler:
):
try:
if timeout is None:
timeout = self.timeout
timeout = _resolve_effective_timeout(self.timeout)
# Prepare data/content parameters to prevent httpx DeprecationWarning (memory leak fix)
request_data, request_content = _prepare_request_data_and_content(data, content)
@ -818,7 +829,7 @@ class AsyncHTTPHandler:
):
try:
if timeout is None:
timeout = self.timeout
timeout = _resolve_effective_timeout(self.timeout)
# Prepare data/content parameters to prevent httpx DeprecationWarning (memory leak fix)
request_data, request_content = _prepare_request_data_and_content(data, content)
@ -882,7 +893,7 @@ class AsyncHTTPHandler:
):
try:
if timeout is None:
timeout = self.timeout
timeout = _resolve_effective_timeout(self.timeout)
# Prepare data/content parameters to prevent httpx DeprecationWarning (memory leak fix)
request_data, request_content = _prepare_request_data_and_content(data, content)
@ -1366,7 +1377,7 @@ class HTTPHandler:
return response
except httpx.TimeoutException:
raise litellm.Timeout(
message=f"Connection timed out after {timeout} seconds.",
message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.",
model="default-model-name",
llm_provider="litellm-httpx-handler",
)
@ -1416,7 +1427,7 @@ class HTTPHandler:
return response
except httpx.TimeoutException:
raise litellm.Timeout(
message=f"Connection timed out after {timeout} seconds.",
message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.",
model="default-model-name",
llm_provider="litellm-httpx-handler",
)
@ -1465,7 +1476,7 @@ class HTTPHandler:
return response
except httpx.TimeoutException:
raise litellm.Timeout(
message=f"Connection timed out after {timeout} seconds.",
message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.",
model="default-model-name",
llm_provider="litellm-httpx-handler",
)
@ -1515,7 +1526,7 @@ class HTTPHandler:
return response
except httpx.TimeoutException:
raise litellm.Timeout(
message=f"Connection timed out after {timeout} seconds.",
message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.",
model="default-model-name",
llm_provider="litellm-httpx-handler",
)

View file

@ -14,6 +14,7 @@ import pytest
from aiohttp import ClientSession, TCPConnector
import litellm
from litellm.constants import COMPLETION_HTTP_FALLBACK_SECONDS, 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,
@ -1547,3 +1548,66 @@ def test_sync_force_ipv4_https_proxy_mount_uses_handler_ca_bundle(
handler.close()
assert response.text == "ok-tls"
class _TimeoutRecordingAsyncClient(httpx.AsyncClient):
async def send(self, request, **kwargs):
self.sent_timeout_extensions = request.extensions.get("timeout")
raise httpx.ConnectTimeout("connect timed out", request=request)
class _RecordingSyncClient(httpx.Client):
def send(self, request, **kwargs):
raise httpx.ReadTimeout("read timed out", request=request)
@pytest.mark.asyncio
async def test_async_post_without_timeout_enforces_default_and_reports_it():
"""
A handler built without a timeout used to pass timeout=None straight to
httpx, which bypasses the client default entirely (no timeouts enforced)
and produced "Connection timed out after None seconds." on failure.
"""
handler = AsyncHTTPHandler()
assert handler.timeout is None
client = _TimeoutRecordingAsyncClient()
handler.client = client
with pytest.raises(litellm.Timeout) as exc_info:
await handler.post("https://example.test/v1/chat", json={"ping": True})
assert client.sent_timeout_extensions["connect"] == HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS
assert client.sent_timeout_extensions["read"] == COMPLETION_HTTP_FALLBACK_SECONDS
assert "None seconds" not in str(exc_info.value)
assert f"{COMPLETION_HTTP_FALLBACK_SECONDS}" in str(exc_info.value)
@pytest.mark.asyncio
async def test_async_post_explicit_timeout_is_enforced_and_reported():
handler = AsyncHTTPHandler()
handler.client = _TimeoutRecordingAsyncClient()
with pytest.raises(litellm.Timeout) as exc_info:
await handler.post("https://example.test/v1/chat", json={"ping": True}, timeout=3.5)
assert "None seconds" not in str(exc_info.value)
assert "3.5" in str(exc_info.value)
def test_sync_post_timeout_message_reports_client_default_when_timeout_unset():
handler = HTTPHandler()
handler.client = _RecordingSyncClient()
with pytest.raises(litellm.Timeout) as exc_info:
handler.post("https://example.test/v1/chat", json={"ping": True})
assert "None seconds" not in str(exc_info.value)
assert f"{COMPLETION_HTTP_FALLBACK_SECONDS}" in str(exc_info.value)
def test_sync_post_timeout_message_reports_explicit_timeout():
handler = HTTPHandler()
handler.client = _RecordingSyncClient()
with pytest.raises(litellm.Timeout) as exc_info:
handler.post("https://example.test/v1/chat", json={"ping": True}, timeout=3.5)
assert "3.5 seconds" in str(exc_info.value)