Repository navigation
Description
HttpOptions.timeout means a whole-request deadline on aiohttp but a per-phase timeout on httpx, and X-Server-Timeout is derived from it on both
Environment: google-genai 2.31.0, aiohttp 3.14.3, httpx 0.28.1, Python 3.14.8. Reproduced offline (below) and live against Vertex AI (global, gemini-3.6-flash).
What happens
HttpOptions.timeout is documented as "Timeout for the request in milliseconds". The SDK turns it into three different things depending on which transport it happens to pick:
| path | client-side meaning | X-Server-Timeout |
|---|---|---|
aiohttp (chosen automatically whenever import aiohttp succeeds) |
aiohttp.ClientTimeout(total=s): the whole response must finish in s (_api_client.py:1673) |
ceil(s) |
httpx (httpx_async_client / async_client_args={"transport": …}, or aiohttp not installed) |
build_request(..., timeout=s) (:1716): httpx reads a scalar as connect/read/write/pool each = s, so it bounds silence between chunks, not total duration |
ceil(s) |
client.aio.interactions.create(..., timeout=s) |
per-phase (_gaos/basesdk.py:237) |
not sent |
The header is populated in _build_request before the transport is chosen (:1545, populate_server_timeout_header at :207-212), so on both generateContent paths Vertex also enforces s as a whole-operation deadline server-side.
Transport selection is implicit: has_aiohttp is just whether the import succeeds (:72-76), and _use_aiohttp() (:1368-1376) only consults client-level options. Installing any unrelated package that depends on aiohttp changes what timeout means for an existing application.
Why it matters
For streaming, the two meanings are not interchangeable. A caller who sets a short timeout to detect a stalled provider gets, on aiohttp, every healthy response longer than timeout cut off mid-stream (client: TimeoutError chained from CancelledError; server: 504 DEADLINE_EXCEEDED). On httpx the same value only fires if the stream goes quiet. Live, with timeout=5500 and a ~1200-word streamed answer on gemini-3.6-flash via Vertex:
- aiohttp: first token at ~1.2s, killed at ~6.5s with ~4,700 chars already streamed (
CancelledError) - httpx, header left to the SDK: killed at ~6.4s by the server (
504 DEADLINE_EXCEEDED) - httpx,
X-Server-Timeoutpinned high by the caller: completes in ~9.5s; a 300 mstimeoutstill fails fast on a stalled first token (ReadTimeout)
Downstream frameworks that treat the value as a dead-air budget (e.g. livekit-plugins-google forwarding conn_options.timeout) hit this in production.
Reproduction (offline; requests are captured at the transport, nothing is sent)
import asyncio
import aiohttp, httpx
from google import genai
from google.genai import types
async def aiohttp_spy(self, method, url, **kw):
print(f" aiohttp -> timeout={kw['timeout']!r} X-Server-Timeout={kw['headers'].get('X-Server-Timeout')}")
raise RuntimeError("captured")
async def httpx_spy(self, request):
print(f" httpx -> timeout={dict(request.extensions['timeout'])} X-Server-Timeout={request.headers.get('X-Server-Timeout')}")
raise httpx.ConnectError("captured")
aiohttp.ClientSession._request = aiohttp_spy
httpx.AsyncHTTPTransport.handle_async_request = httpx_spy
async def run(label, client):
print(label)
config = types.GenerateContentConfig(http_options=types.HttpOptions(timeout=5500))
try:
async for _ in await client.aio.models.generate_content_stream(model="gemini-3.6-flash", contents="hi", config=config):
pass
except Exception:
pass
async def main():
await run("client A: default transport", genai.Client(api_key="x"))
await run("client B: httpx_async_client", genai.Client(
api_key="x", http_options=types.HttpOptions(httpx_async_client=httpx.AsyncClient())))
asyncio.run(main())Output:
client A: default transport
aiohttp -> timeout=ClientTimeout(total=5.5, connect=None, sock_read=None, sock_connect=None, ceil_threshold=5) X-Server-Timeout=6
client B: httpx_async_client
httpx -> timeout={'connect': 5.5, 'read': 5.5, 'write': 5.5, 'pool': 5.5} X-Server-Timeout=6
Expected
One documented meaning for HttpOptions.timeout regardless of transport. Either direction would be fine, as long as it's stated and the two paths agree:
- Document the current behavior and the implicit transport selection.
- Make the client side consistent: per-phase on aiohttp (
ClientTimeout(sock_connect=s, sock_read=s)) or a true total on httpx. - Decouple the server deadline: only send
X-Server-Timeoutwhen the caller sets it (or add a separateserver_timeoutoption). Today there's no way to get a client-side silence bound without also getting a server-side duration cap, short of overriding the header by hand. - Optionally expose an explicit read/silence timeout for streaming callers, which is what
timeoutalready is on the Interactions path.
Related: #1330 (short timeouts surface as confusing errors), #3158 (X-Server-Timeout leaking into client default headers), #620 (the PR that added the header).
Activity
Venkaiahbabuneelam commentedon Oct 22, 2026
Hi @joearasin,
Thank you for the detailed report and reproduction script! We have reproduced this behavior on our end and confirmed the inconsistency between
aiohttp(ClientTimeout(total=s)),httpx(per-phaseconnect/read/write/pool=s), and theX-Server-Timeoutheader generation in_api_client.py.We have escalated this issue internally to the SDK engineering team.
Thanks