Introduction
The httpx async client connection pool exhaustion error occurs when all available connections in the pool are in use and new requests cannot be established. Unlike the synchronous requests library, httpx's async client maintains a persistent connection pool across multiple requests for efficiency. When the pool is exhausted, new requests queue indefinitely waiting for a connection, eventually causing PoolTimeout errors. This is particularly common in high-concurrency async applications like FastAPI services that make outbound HTTP calls to multiple downstream services.
Symptoms
httpx.PoolTimeout: Unable to acquire connection within 5.0 secondsOr:
httpcore.PoolTimeout: The connection pool is full. Unable to acquire a connection within 5.0 seconds.Under load, requests queue up:
Task exception was never retrieved
future: <Task finished name='Task-42' coro=<fetch_data() done, defined at app.py:15> exception=PoolTimeout()>Common Causes
- Creating a new client per request: Each
AsyncClient()creates its own pool, consuming file descriptors - Not closing response bodies: Forgetting
await response.aclose()leaves connections in use - Connection limits too low: Default
limits=Limits(max_connections=100)may be insufficient for high-concurrency services - Long-lived idle connections: Connections held open to idle services consume pool slots
- Not using a shared client instance: Creating clients in tight loops instead of sharing a singleton
- HTTP/2 multiplexing not enabled: HTTP/1.1 requires one connection per concurrent request; HTTP/2 can multiplex
Step-by-Step Fix
Step 1: Use a shared client with proper limits
```python import httpx from httpx import Limits
# Create a single shared client for the application lifetime client = httpx.AsyncClient( base_url="https://api.downstream-service.com", limits=Limits( max_connections=200, # Total connections across all hosts max_keepalive_connections=50, # Idle connections to keep ), timeout=httpx.Timeout( connect=5.0, read=30.0, write=5.0, pool=5.0, # Time to wait for a connection from the pool ), )
# Use the shared client for all requests async def fetch_user(user_id: int): response = await client.get(f"/users/{user_id}") response.raise_for_status() return response.json() ```
Step 2: Properly manage client lifecycle with async context managers
```python import httpx
async def main(): async with httpx.AsyncClient( base_url="https://api.example.com", limits=httpx.Limits(max_connections=100), ) as client: # All requests share the same connection pool # Pool is automatically closed when exiting the context tasks = [ client.get(f"/items/{i}") for i in range(1, 51) ] responses = await asyncio.gather(*tasks, return_exceptions=True) ```
For FastAPI applications, use lifespan events:
```python from contextlib import asynccontextmanager from fastapi import FastAPI import httpx
@asynccontextmanager async def lifespan(app: FastAPI): app.http_client = httpx.AsyncClient( base_url="https://api.downstream.com", limits=httpx.Limits(max_connections=200), ) yield await app.http_client.aclose()
app = FastAPI(lifespan=lifespan) ```
Step 3: Enable HTTP/2 for connection multiplexing
```python import httpx
client = httpx.AsyncClient( base_url="https://api.example.com", http2=True, # Multiplex multiple requests over single connection limits=httpx.Limits( max_connections=20, # Fewer connections needed with HTTP/2 ), )
# Multiple concurrent requests share the same TCP connection results = await asyncio.gather( client.get("/resource/1"), client.get("/resource/2"), client.get("/resource/3"), ) ```
HTTP/2 can handle hundreds of concurrent streams over a single TCP connection, dramatically reducing pool pressure.
Step 4: Monitor pool utilization
```python import httpx
client = httpx.AsyncClient( limits=httpx.Limits(max_connections=100), event_hooks={ "request": [lambda r: print(f"Request: {r.method} {r.url}")], "response": [lambda r: print(f"Response: {r.status_code}")], }, )
# Check pool stats (available in httpx 0.24+) print(client._transport._pool) ```
Prevention
- Always use
async with AsyncClient()or manage lifecycle with FastAPI lifespan events - Set
pooltimeout lower thanreadtimeout to fail fast when the pool is full - Use HTTP/2 when the downstream service supports it
- Set
max_keepalive_connectionsto roughly 20-25% ofmax_connections - Monitor connection pool metrics in production using APM tools
- Use
httpx.AsyncClient(limits=Limits(max_connections=None))only for testing, never production