From 2f02e33db5903bb9b61dcc7cbd6b731a7a2739d9 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 27 Aug 2026 23:27:24 +0800 Subject: [PATCH] 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 --- litellm/llms/custom_httpx/http_handler.py | 27 +++++--- .../llms/custom_httpx/test_http_handler.py | 64 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index e1f0fc9e7d3..e2413469264 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -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", ) diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 9e64bfafa54..3aa65a94ded 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -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)