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)