DEV Community

Libme
Libme

Posted on

httpx.PoolTimeout: Why Your Async Python Client Runs Out of Connections (and How to Fix It)

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
🔴 Hot News: libme/httpxpooltimeout why your async python client runs out of connections and how to fix it hm - ประเด็นร้อน 🔴 Hot News: libme/httpxpooltimeout why your async python client runs out of connections and how to fix it hm - ประเด็นร้อน

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())
🔴 Hot News: libme/httpxpooltimeout why your async python client runs out of connections and how to fix it hm - ประเด็นร้อน 🔴 Hot News: libme/httpxpooltimeout why your async python client runs out of connections and how to fix it hm - ประเด็นร้อน

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))
🔴 Hot News: libme/httpxpooltimeout why your async python client runs out of connections and how to fix it hm - ประเด็นร้อน 🔴 Hot News: libme/httpxpooltimeout why your async python client runs out of connections and how to fix it hm - ประเด็นร้อน

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.

Related reading

Top comments (0)