From 414848bdcd9e8f2321c764922ec1676c7a02424a Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 27 Aug 2026 23:27:24 +0800 Subject: [PATCH 1/2] 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 | 69 ++++++++++++++++++- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 3351d34b16f..ab168c4e404 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -178,6 +178,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 @@ -827,7 +838,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) @@ -899,7 +910,7 @@ class AsyncHTTPHandler: _follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT 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) @@ -971,7 +982,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) @@ -1036,7 +1047,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) @@ -1540,7 +1551,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", ) @@ -1592,7 +1603,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", ) @@ -1643,7 +1654,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", ) @@ -1695,7 +1706,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/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index b6b0db69162..0b768c1666f 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -17,6 +17,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, @@ -1724,8 +1725,6 @@ def test_sync_force_ipv4_https_proxy_mount_uses_handler_ca_bundle( handler.close() assert response.text == "ok-tls" - - @pytest.mark.asyncio async def test_put_can_refuse_to_follow_a_redirect(): """The client follows redirects by default; a caller uploading to a URL it did not choose must be able to opt out.""" @@ -2031,7 +2030,6 @@ def test_post_delay_exceeds_per_request_timeout_raises(): server.shutdown() server.server_close() - def test_realtime_ssl_for_url_sends_no_tls_argument_for_a_plain_ws_endpoint() -> None: assert ( realtime_ssl_for_url("ws://127.0.0.1:8080/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent") @@ -2051,3 +2049,68 @@ def test_realtime_ssl_for_url_turns_ssl_verify_false_into_an_unverified_tls_cont assert isinstance(selected, ssl.SSLContext) assert selected.verify_mode == ssl.CERT_NONE assert selected.check_hostname is False + + +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) From 9e650d138a7ae607b8e9403821a1e6c2f228b88a Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 30 Aug 2026 16:20:35 +0800 Subject: [PATCH 2/2] fix(custom_httpx): report handler timeout in sync errors --- litellm/llms/custom_httpx/http_handler.py | 18 ++++++++++++------ .../llms/custom_httpx/test_http_handler.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index ab168c4e404..0e4afa06422 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -178,15 +178,21 @@ 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: +def _resolve_effective_timeout( + timeout: float | httpx.Timeout | None, + fallback_timeout: float | httpx.Timeout | None = 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. + `fallback_timeout` lets error messages preserve a handler-level timeout. """ - return timeout if timeout is not None else _DEFAULT_TIMEOUT + if timeout is not None: + return timeout + return fallback_timeout if fallback_timeout is not None else _DEFAULT_TIMEOUT _CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER: Final = 2 @@ -1551,7 +1557,7 @@ class HTTPHandler: return response except httpx.TimeoutException: raise litellm.Timeout( - message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.", + message=f"Connection timed out after {_resolve_effective_timeout(timeout, self.timeout)} seconds.", model="default-model-name", llm_provider="litellm-httpx-handler", ) @@ -1603,7 +1609,7 @@ class HTTPHandler: return response except httpx.TimeoutException: raise litellm.Timeout( - message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.", + message=f"Connection timed out after {_resolve_effective_timeout(timeout, self.timeout)} seconds.", model="default-model-name", llm_provider="litellm-httpx-handler", ) @@ -1654,7 +1660,7 @@ class HTTPHandler: return response except httpx.TimeoutException: raise litellm.Timeout( - message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.", + message=f"Connection timed out after {_resolve_effective_timeout(timeout, self.timeout)} seconds.", model="default-model-name", llm_provider="litellm-httpx-handler", ) @@ -1706,7 +1712,7 @@ class HTTPHandler: return response except httpx.TimeoutException: raise litellm.Timeout( - message=f"Connection timed out after {_resolve_effective_timeout(timeout)} seconds.", + message=f"Connection timed out after {_resolve_effective_timeout(timeout, self.timeout)} seconds.", model="default-model-name", llm_provider="litellm-httpx-handler", ) diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index 0b768c1666f..3f3db3cfbdf 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -2106,6 +2106,16 @@ def test_sync_post_timeout_message_reports_client_default_when_timeout_unset(): assert f"{COMPLETION_HTTP_FALLBACK_SECONDS}" in str(exc_info.value) +def test_sync_post_timeout_message_reports_handler_timeout_when_request_timeout_unset(): + handler = HTTPHandler(timeout=7.25) + handler.client = _RecordingSyncClient() + + with pytest.raises(litellm.Timeout) as exc_info: + handler.post("https://example.test/v1/chat", json={"ping": True}) + + assert "7.25 seconds" in str(exc_info.value) + + def test_sync_post_timeout_message_reports_explicit_timeout(): handler = HTTPHandler() handler.client = _RecordingSyncClient()