Modified the aiohttp transport to derive a total deadline from the per-phase timeout values, ensuring that long-running reasoning models don't hit infrastructure idle timeouts before responding. This change allows for better handling of models like GPT-5-PRO that may require more time to generate responses.

This commit is contained in:
RoyVivat 2026-03-19 13:08:26 -07:00
parent 81dadb698a
commit d96442750e
2 changed files with 112 additions and 0 deletions

View file

@ -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"),

View file

@ -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["<key>"]`` 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"""