httpx AsyncClient — Practical Async HTTP in Python
Think of httpx.AsyncClient as a well-maintained, long-distance courier vehicle. If you buy a brand new vehicle and register it for every single delivery, you'll spend most of your time on setup and very little on actual deliveries. Instead, you should keep one vehicle running for all your deliveries.
The Setup
A DevOps engineer designs an API monitoring service that pings 50 distinct health endpoints every 10 seconds, but observes the server quickly running out of file descriptors and suffering high latency.
What Does This Print?
import asyncio
import httpx
import time
async def fetch_endpoint(url):
# CRITICAL ANTI-PATTERN: Instantiating client on every single request
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.status_code
async def main():
urls = ["https://httpbin.org/status/200"] * 10
start = time.perf_counter()
tasks = [fetch_endpoint(url) for url in urls]
results = await asyncio.gather(*tasks)
print(f"Fetched {len(results)} pages in {time.perf_counter() - start:.2f}s")
asyncio.run(main())
The Output
The code completes slowly because it establishes a completely new TCP connection and negotiates a new SSL TLS handshake for every single request. Under load, this leaves thousands of sockets in the TIME_WAIT state, leading to file descriptor exhaustion (socket leaks) on the host machine.
Why Python Does This
httpx.AsyncClient is designed to manage an internal TCP connection pool. When you execute HTTP requests inside an async with httpx.AsyncClient() block, the client retains established TCP connections and reuses them for subsequent requests to the same host (using HTTP Keep-Alive). If you construct and destroy a client on every call, the socket is immediately closed after the request, forcing the operating system to negotiate new connections from scratch. This introduces significant network and CPU overhead due to redundant TCP handshakes, TLS handshakes, and DNS resolutions.
The Fix
import asyncio
import httpx
import time
# Accept the shared, thread-safe AsyncClient as an argument
async def fetch_endpoint(client, url):
response = await client.get(url)
return response.status_code
async def main():
urls = ["https://httpbin.org/status/200"] * 10
start = time.perf_counter()
# Correct: Instantiate a single Client to share across all requests
async with httpx.AsyncClient() as client:
tasks = [fetch_endpoint(client, url) for url in urls]
results = await asyncio.gather(*tasks)
print(f"Fetched {len(results)} pages in {time.perf_counter() - start:.2f}s")
asyncio.run(main())
Reusing a single httpx.AsyncClient instance allows it to maintain an internal connection pool, keeping TCP connections alive and reusing them for subsequent requests to the same host. This significantly reduces network overhead by avoiding repeated connection establishments and SSL/TLS handshakes, improving performance.
How This Fails in Real Systems
A microservice built to aggregate pricing data from various flight APIs crashed every few minutes. Under traffic peaks, the error logs showed OSError: [Errno 24] Too many open files. The team discovered that each outgoing API request spawned a new httpx.AsyncClient. This exhausted the host's socket resources. The issue was resolved immediately by defining a single, global httpx.AsyncClient inside a FastAPI lifecycle context manager and injecting it into the endpoints.
Key Takeaway
httpx.AsyncClient instance for every HTTP request, leading to unnecessary overhead from establishing new TCP connections and SSL/TLS handshakes repeatedly.