Setting Connect, Read and Total Timeouts in Async HTTP¶
A request configured with timeout=5 took ninety seconds. Nothing is broken: the five seconds was a read timeout, which resets every time a byte arrives, and the server was dribbling out a chunked response one line at a time. The same misunderstanding produces the opposite surprise too — a client that gives up after five seconds of a legitimately slow streaming download, or one that waits indefinitely for a connection slot because the pool timeout was never set.
HTTP clients expose several independent timers, and each bounds a different phase. Getting this right means knowing which phase each timer covers, which ones have no default, and — the key insight — that no per-phase timer bounds the whole request, so a total deadline has to be imposed on top. This guide does that for both httpx and aiohttp.
Prerequisites¶
- Python 3.11+ for
asyncio.timeout().
pip install httpx aiohttp
- The async HTTP clients and servers overview, and choosing asyncio.timeout vs wait_for for the outer bound applied in step 4.
1. Map each timer to the phase it bounds¶
A request has four phases that can stall independently, and each has its own timer.
# httpx — every phase is explicit
import httpx
timeout = httpx.Timeout(
connect=2.0, # DNS + TCP + TLS handshake
read=5.0, # gap between two received chunks (RESETS on each chunk)
write=5.0, # gap while sending the request body
pool=1.0, # waiting for a free connection from the pool
)
client = httpx.AsyncClient(timeout=timeout)
The read timer is the one that surprises people: it is an inactivity timeout, not a duration. A server that sends one byte every four seconds keeps a five-second read timeout satisfied forever. The pool timeout is the one most often left unset, and it is what turns pool exhaustion into an unbounded wait rather than a fast, countable error. Verify: point the client at a stub that sleeps four seconds between chunks and confirm a five-second read timeout does not fire.
2. Configure aiohttp's equivalents¶
aiohttp names them differently and, unusually, does provide a total.
import aiohttp
timeout = aiohttp.ClientTimeout(
total=10.0, # whole request, including redirects and body read
connect=2.0, # acquiring a connection: pool wait + TCP/TLS
sock_connect=2.0, # just the TCP connect to the peer
sock_read=5.0, # inactivity between socket reads
)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
body = await response.read()
Note the difference between connect and sock_connect: the former includes waiting for a free connection from the pool, the latter covers only the socket connection itself, so a pool that is saturated is visible in the gap between them. total covers everything, including reading the body — but only if you read the body inside the context manager, which is why the await response.read() placement matters. Verify: saturate the connector and confirm the error mentions connection acquisition rather than a socket timeout.
3. Set values from measurements, not habit¶
Each timer answers a different question about your dependency.
# Reasoning, per timer:
CONNECT = 2.0 # TCP+TLS to a same-region service: ~50 ms typical.
# 2 s allows a retry of a lost SYN without a long stall.
READ = 5.0 # > the longest legitimate gap BETWEEN chunks, not the whole body.
POOL = 0.5 # if no connection is free in 500 ms, the pool is too small:
# fail fast and let the metric say so.
TOTAL = 8.0 # the caller's patience; must be < the upstream caller's own budget.
Connect timeouts should be short — a healthy connect is milliseconds, and a slow one usually means a dead host that a retry to another address will fix faster than waiting. Read timeouts must exceed the largest legitimate inter-chunk gap, which for a streaming endpoint may be much larger than for a JSON API. Pool timeouts should be short deliberately: a long wait for a connection is capacity queueing dressed up as latency. Verify: in normal operation, none of the four fires; each one firing tells you a distinct thing.
4. Impose a total deadline yourself¶
httpx has no total timeout, and even aiohttp's does not cover everything you might do with the response afterwards. Wrap the whole operation.
import asyncio
import httpx
async def fetch_json(client: httpx.AsyncClient, url: str, budget: float = 8.0):
async with asyncio.timeout(budget): # covers connect + read + parse
response = await client.get(url)
response.raise_for_status()
return response.json() # parsing is inside the budget too
This is the only bound that maps to what the caller actually cares about: "answer within eight seconds or give up". Keep it strictly below your own caller's remaining deadline so the budget shrinks as it propagates down the call chain rather than growing. Verify: against a server that trickles a response indefinitely, the request now fails at the budget rather than never.
5. Distinguish the failures in your metrics¶
All four timers raise different exceptions, and collapsing them discards the diagnosis.
import httpx
async def call(client: httpx.AsyncClient, url: str) -> httpx.Response | None:
try:
return await client.get(url)
except httpx.ConnectTimeout:
METRICS["connect_timeout"] += 1 # host unreachable or overloaded
except httpx.ReadTimeout:
METRICS["read_timeout"] += 1 # server accepted, then went quiet
except httpx.PoolTimeout:
METRICS["pool_timeout"] += 1 # OUR pool is too small — not their fault
except httpx.WriteTimeout:
METRICS["write_timeout"] += 1 # peer stopped reading our body
return None
The distinction that matters most operationally is pool timeouts: they are the only one of the four that indicates a problem on your side, and they are the signal to resize the pool or reduce concurrency, as covered in sizing async connection pools. Verify: each counter can be driven independently in staging — throttle the pool, block a port, and stall a response to trigger them one at a time.
Verification¶
- Each timer fires on its own scenario. A blackholed port trips connect, a silent server trips read, a saturated pool trips pool, and none of them trip during healthy traffic.
- The total holds. No request outlives the request-level budget, even against a server that streams indefinitely.
- Metrics separate the causes. Pool timeouts are visible as a distinct series, not buried in a generic timeout count.
Pitfalls and edge cases¶
- Assuming a read timeout bounds the request. It resets on every chunk; a slow drip satisfies it forever.
- No pool timeout. Connection-pool exhaustion becomes an unbounded, invisible wait instead of a countable error.
timeout=5as a single scalar. Inhttpxthat sets all four phases to 5 s, which is usually too long for connect and too short for a streaming read.- Timeouts larger than the caller's budget. Your client waits for a response nobody is still listening for; shrink budgets as they propagate.
- Per-request timeouts fighting a client default. Passing
timeout=on a request replaces the client's configuration for that call; be explicit about which wins. - Streaming responses with a total timeout. A legitimate large download will trip it; use a read timeout plus a byte-rate check instead.
- Retrying a connect timeout without jitter. A blackholed host attracts synchronised retries from every worker.
Frequently Asked Questions¶
What is the difference between connect, read and total timeouts?
Connect bounds establishing the connection (DNS, TCP, TLS, and in some clients waiting for a pool slot). Read bounds the gap between received chunks and resets each time data arrives, so it is an inactivity timer rather than a duration. Total bounds the whole operation end to end. Only the total corresponds to what a caller means by "answer within N seconds".
Why did my httpx request take much longer than the timeout I set?
Because httpx's timeouts are per phase, not total: a server that sends a byte every few seconds keeps resetting the read timer indefinitely. Wrap the call in asyncio.timeout() with the deadline you actually mean, which also covers parsing the response, not just receiving it.
What should the pool timeout be set to?
Short — a few hundred milliseconds. Waiting for a free connection is queueing, not work, and a long pool timeout hides capacity problems as latency. A fast PoolTimeout is a clear signal to increase the pool size or reduce concurrency, and it is the only one of the four timeouts that points at your own configuration rather than the upstream.
Does aiohttp's total timeout cover reading the response body?
Yes, provided the body is read within the request context. ClientTimeout(total=...) applies to the whole operation including redirects and the body read, so a response you start consuming outside the context manager is no longer covered. Keep the read inside the async with block, or add your own asyncio.timeout() around the whole thing.
Related¶
- Async HTTP Clients & Servers — the parent overview: sessions, clients, and connection reuse.
- Sizing async connection pools for throughput — what a pool timeout is telling you to change.
- Timeouts & Deadlines — the budget model behind the total deadline.