diff --git a/litellm/llms/custom_httpx/aiohttp_transport.py b/litellm/llms/custom_httpx/aiohttp_transport.py index 132191c946c..efba32f4125 100644 --- a/litellm/llms/custom_httpx/aiohttp_transport.py +++ b/litellm/llms/custom_httpx/aiohttp_transport.py @@ -264,6 +264,14 @@ class LiteLLMAiohttpTransport(AiohttpTransport): # Only pass ssl kwarg when explicitly configured, to avoid # overriding the session/connector defaults with None (which is # not a valid value for aiohttp's ssl parameter). + + # Derive a `total` deadline from the per-phase values so that + # aiohttp doesn't leave it as None. Without this, long-running + # reasoning models (e.g. GPT-5-PRO) can hit infrastructure idle + # timeouts (~60s) before the model responds. + _phase_values = [v for v in timeout.values() if v is not None] + _total = max(_phase_values) if _phase_values else None + request_kwargs: Dict[str, Any] = { "method": request.method, "url": YarlURL(str(request.url), encoded=True), @@ -272,6 +280,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): "allow_redirects": False, "auto_decompress": False, "timeout": ClientTimeout( + total=_total, sock_connect=timeout.get("connect"), sock_read=timeout.get("read"), connect=timeout.get("pool"), diff --git a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py index 6e2e60ba0dd..86075a3f496 100644 --- a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py +++ b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py @@ -500,6 +500,46 @@ def _make_mock_session(closed=False): return MockSession() +def _make_capturing_session(captured: dict): + """ + Helper to create a fake session that records the kwargs passed to request(). + Use ``captured[""]`` after the transport call to inspect what was sent. + """ + + class CapturingSession: + def __init__(self): + self.closed = False + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self._loop = None + + def request(self, *args, **kwargs): + captured.update(kwargs) + + class Resp: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + @property + def content(self): + class C: + async def iter_chunked(self, size): + yield b"" + + return C() + + return Resp() + + return CapturingSession() + + @pytest.mark.asyncio async def test_handle_closed_session_before_request(): """Test that closed sessions are detected and recreated""" @@ -516,6 +556,69 @@ async def test_handle_closed_session_before_request(): assert response.status_code == 200 +@pytest.mark.asyncio +async def test_client_timeout_total_set_to_max_phase_value(): + """ + Regression test for issue #22747: ClientTimeout.total must be set to the + maximum phase timeout so aiohttp has an overall deadline. + + Without this fix, total=None caused aiohttp to have no overall deadline, + allowing infrastructure idle timeouts (~60s) to kill connections before + long-running reasoning models (e.g. GPT-5-PRO) responded. + + httpx converts ``timeout=300`` into all four phase keys set to 300. + The ``write`` key is included in ``_phase_values`` via ``.values()`` but is + not mapped to any aiohttp ``ClientTimeout`` field — that's expected and fine. + """ + captured: dict = {} + transport = LiteLLMAiohttpTransport(client=lambda: _make_capturing_session(captured)) # type: ignore + request = httpx.Request("GET", "http://example.com") + request.extensions["timeout"] = { + "connect": 300.0, + "read": 300.0, + "write": 300.0, + "pool": 300.0, + } + await transport.handle_async_request(request) + + timeout = captured["timeout"] + assert timeout is not None + assert timeout.total == 300.0, f"Expected total=300.0, got total={timeout.total}" + assert timeout.sock_connect == 300.0 + assert timeout.sock_read == 300.0 + + +@pytest.mark.asyncio +async def test_client_timeout_total_uses_max_when_phases_differ(): + """total should be the max of all provided phase timeouts.""" + captured: dict = {} + transport = LiteLLMAiohttpTransport(client=lambda: _make_capturing_session(captured)) # type: ignore + request = httpx.Request("GET", "http://example.com") + request.extensions["timeout"] = { + "connect": 10.0, + "read": 600.0, # Longest — should become total + "pool": 5.0, + } + await transport.handle_async_request(request) + + timeout = captured["timeout"] + assert timeout.total == 600.0, f"Expected total=600.0, got total={timeout.total}" + + +@pytest.mark.asyncio +async def test_client_timeout_total_is_none_when_no_phases_set(): + """When no phase timeouts are provided, total should remain None.""" + captured: dict = {} + transport = LiteLLMAiohttpTransport(client=lambda: _make_capturing_session(captured)) # type: ignore + request = httpx.Request("GET", "http://example.com") + # Empty timeout dict — simulates a request with no timeout configured + request.extensions["timeout"] = {} + await transport.handle_async_request(request) + + timeout = captured["timeout"] + assert timeout.total is None, f"Expected total=None, got total={timeout.total}" + + @pytest.mark.asyncio async def test_handle_session_closed_during_request(): """Test that sessions closed during request are handled with retry"""