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)