Skip to content

Inconsistent Timeout Behavior between httpx and aiohttp #3165

Open

Description

@joearasin

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-Timeout pinned high by the caller: completes in ~9.5s; a 300 ms timeout still 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:

  1. Document the current behavior and the implicit transport selection.
  2. Make the client side consistent: per-phase on aiohttp (ClientTimeout(sock_connect=s, sock_read=s)) or a true total on httpx.
  3. Decouple the server deadline: only send X-Server-Timeout when the caller sets it (or add a separate server_timeout option). 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.
  4. Optionally expose an explicit read/silence timeout for streaming callers, which is what timeout already 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

  1. added
    type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.
    priority: p2Moderately-important priority. Fix may not be included in next release.
    on Oct 19, 2026
  2. Venkaiahbabuneelam commented on Oct 22, 2026

    @Venkaiahbabuneelam

    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-phase connect/read/write/pool=s), and the X-Server-Timeout header generation in _api_client.py.
    We have escalated this issue internally to the SDK engineering team.

    Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

priority: p2Moderately-important priority. Fix may not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions