httpx.PoolTimeout means a request waited for a free connection in the client's pool and never got one before the pool timeout expired. The server is usually fine. The cause is almost always one of three things: you fire more concurrent requests than the pool allows, you leak responses by never closing them, or you create a client per request and defeat pooling altogether. The fixes are a semaphore, async with on streams, and one shared client.
What does PoolTimeout actually mean?
The traceback looks like this:
httpx.PoolTimeout
Often it has no message at all, which is why people go hunting in the network layer. It is different from ConnectTimeout (TCP handshake too slow) and ReadTimeout (server too slow to send bytes). PoolTimeout is purely local: the client has no idle connection to hand out and is already at its connection ceiling.
As of httpx 0.2x, the defaults are a 5 second timeout for each of connect, read, write, and pool, and Limits(max_connections=100, max_keepalive_connections=20). Check your installed version's docs, since defaults are the kind of thing that gets tuned between releases.
Takeaway: PoolTimeout is a queueing symptom on your side of the wire, not a slow-server symptom.
Why do I hit it? Three causes, in order of likelihood
1. Unbounded fan-out. asyncio.gather over thousands of coroutines sharing one client means thousands of requests competing for 100 slots. Everything past the first 100 waits, and anything waiting longer than 5 seconds raises. With a slow upstream, this happens fast.
2. Leaked responses. A streamed response holds its connection until the body is fully read or the response is closed. If you open a stream, hit an exception or an early return, and never close it, that connection is gone from the pool permanently. After 100 leaks, every request times out, even though traffic looks low. This is the one that bites in production, because it only shows up hours after deploy.
3. A client per request. Creating httpx.AsyncClient() inside a handler gives you a fresh pool each time, a fresh TLS handshake each time, and sockets that linger until garbage collection. It rarely raises PoolTimeout, but it causes the slow, flaky behavior people blame on the network.
How do I reproduce the leak?
This script leaks on purpose against any reachable URL. It never closes the streamed responses, then tries a normal request:
import asyncio
import httpx
URL = "https://example.com/"
async def main():
limits = httpx.Limits(max_connections=5, max_keepalive_connections=5)
timeout = httpx.Timeout(5.0, pool=2.0)
async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
leaked = []
for _ in range(5):
req = client.build_request("GET", URL)
# stream=True returns before the body is read; we never close it
leaked.append(await client.send(req, stream=True))
try:
await client.get(URL)
except httpx.PoolTimeout:
print("PoolTimeout: all 5 connections are held by unclosed responses")
for r in leaked:
await r.aclose()
asyncio.run(main())
The tell in real systems is that the error rate does not track traffic. If PoolTimeout climbs steadily and never recovers until restart, suspect a leak, not load.
Takeaway: a leak looks like a pool that shrinks over time; overload looks like a pool that is full only during bursts.
How do I fix it?
Cap concurrency below the pool size with a semaphore, and always close streams with a context manager:
import asyncio
import httpx
async def fetch_all(urls: list[str], concurrency: int = 50) -> list[int]:
limits = httpx.Limits(
max_connections=concurrency,
max_keepalive_connections=concurrency,
)
timeout = httpx.Timeout(10.0, pool=30.0)
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
async def one(url: str) -> int:
async with sem:
async with client.stream("GET", url) as resp:
async for _ in resp.aiter_bytes():
pass
return resp.status_code
return await asyncio.gather(*(one(u) for u in urls))
Two details matter. The semaphore is acquired before the request starts, so waiting for a slot is not counted against any httpx timeout. And async with client.stream(...) guarantees the connection is released even if the loop body raises.
Resist the urge to just raise max_connections to 10,000. That moves the queue from your process to the upstream, and many APIs will answer with 429s or reset connections instead. Pair this with the retry handling you would use anyway for rate limits.
httpx vs aiohttp vs requests: how do their pools compare?
| Client | Pool knob | Default behavior when full | Gotcha |
|---|---|---|---|
| httpx (async) | Limits(max_connections=...) |
Waits, then raises PoolTimeout
|
Unclosed streams hold slots forever |
| aiohttp | TCPConnector(limit=...) |
Waits indefinitely unless you set a total timeout | No error at all, just a silent hang |
| requests | HTTPAdapter(pool_maxsize=...) |
Opens extra connections and discards them, logging "Connection pool is full, discarding connection" | Only a warning, so thread pools degrade quietly |
I prefer httpx's behavior here. A loud PoolTimeout is easier to alert on than aiohttp's silent wait. If you are choosing a client today and need HTTP/2 or a sync and async API from one library, httpx is the one that gives you both. The real drawback is that its per-request overhead is higher than aiohttp's in high-throughput fan-out. I have not benchmarked that myself, so measure with your own workload before switching for speed.
Takeaway: all three clients cap connections by default; they differ in whether you find out loudly (httpx), silently (aiohttp), or as a log line (requests).
How do I confirm the fix?
Log pool pressure instead of guessing. Count in-flight requests with a counter around the semaphore, and alert when it sits at the cap for long periods. If in-flight stays at the cap while throughput is flat, the upstream is the bottleneck. If in-flight creeps up while traffic is steady, you still have a leak, so search for every client.send(..., stream=True) and every early return inside a stream block.
FAQ
What is the default connection limit in httpx?
As of recent 0.2x releases, max_connections is 100 and max_keepalive_connections is 20, with a 5 second pool timeout. Verify against the docs for your installed version.
Should I increase the pool timeout to fix PoolTimeout?
Only if requests are legitimately bursty and short-lived. If the pool is exhausted by leaks, a longer timeout just makes each failure slower.
Is it safe to share one httpx.AsyncClient across tasks?
Yes. It is designed to be shared and reused for the life of your application. Create it once at startup, close it at shutdown.
Bottom line
If you see PoolTimeout, check for leaked streams first, then bound your fan-out with a semaphore sized at or below max_connections. Use one long-lived client per upstream host group. Raise limits only after confirming the upstream can absorb the extra concurrency.
Top comments (0)