httpx — sync and async HTTP clients
Connection pools, timeouts, retries with tenacity, and the redirect and TLS pitfalls to plan for.
Why this matters
Almost every Python program calls HTTP — APIs, LLMs, scrapers, microservices. requests taught us a beautiful sync API; httpx brought the same API to async with HTTP/2 + connection pooling + streaming. Combine it with tenacity for retries and you have production-grade outbound HTTP.
Learning objectives
- Use
httpxsynchronously and asynchronously. - Reuse
Client/AsyncClientfor connection pooling. - Stream large responses without loading into memory.
- Apply retries / timeouts / backoff with tenacity.
- Test HTTP calls with
MockTransport.
1. Quick start — httpx is requests's spiritual successor
uv add httpximport httpx
r = httpx.get("https://httpbin.org/get", timeout=10)
r.status_code; r.headers; r.text; r.json()
r.raise_for_status() # raise HTTPStatusError on 4xx/5xxAPI matches requests for the simple cases. Bonus: HTTP/2 support, async, type hints, modern defaults.
2. Reuse a Client (always)
httpx.get(...) creates a new client per call → opens a new TCP/TLS connection → throws it away. Slow. Use a long-lived client:
with httpx.Client(timeout=10, http2=True, headers={"User-Agent": "myapp/1.0"}) as client:
for url in urls:
r = client.get(url)
...Client pools connections (keep-alive), reuses TLS sessions, and reuses HTTP/2 multiplexing if the server supports it. 5-50× faster than per-call.
In long-lived apps (FastAPI), create the client once in lifespan:
@asynccontextmanager
async def lifespan(app):
app.state.http = httpx.AsyncClient(timeout=10)
yield
await app.state.http.aclose()3. Async client
import asyncio, httpx
async def main():
async with httpx.AsyncClient(timeout=10) as client:
rs = await asyncio.gather(*[client.get(u) for u in urls])
for r in rs:
print(r.status_code, r.headers["content-type"])
asyncio.run(main())For thousands of concurrent fetches, async + bounded concurrency:
sem = asyncio.Semaphore(20)
async with httpx.AsyncClient() as client:
async def bound(url):
async with sem:
return await client.get(url)
rs = await asyncio.gather(*[bound(u) for u in urls])4. Common request patterns
# Query params
client.get("/items", params={"page": 2, "size": 10})
# JSON body
client.post("/users", json={"name": "Ada", "age": 30})
# Form
client.post("/login", data={"username": "ada", "password": "secret"})
# Multipart
with open("photo.jpg", "rb") as f:
client.post("/upload", files={"file": ("photo.jpg", f, "image/jpeg")})
# Custom headers / auth
client.get("/me", headers={"Authorization": f"Bearer {token}"})
client.get("/me", auth=("user", "pass")) # basic auth
client.get("/me", auth=httpx.DigestAuth("user", "pass"))5. Streaming responses
For large downloads (don't load 5 GB into memory):
with httpx.Client() as client:
with client.stream("GET", url) as r:
r.raise_for_status()
with open("big.bin", "wb") as f:
for chunk in r.iter_bytes(chunk_size=65536):
f.write(chunk)Async equivalent:
async with httpx.AsyncClient() as client:
async with client.stream("GET", url) as r:
async for chunk in r.aiter_bytes():
await process(chunk)Streaming LLM responses (SSE)
async with client.stream("POST", llm_url, json=payload) as r:
async for line in r.aiter_lines():
if line.startswith("data: "):
event = json.loads(line.removeprefix("data: "))
yield event["text"]Phase 8.4 covers SSE + websockets in depth.
6. Streaming uploads
def gen_chunks():
for chunk in big_iterable:
yield chunk
client.post(url, content=gen_chunks(), headers={"Content-Length": str(total)})The body is sent in chunks; memory stays low even for huge uploads.
7. Timeouts — always set them
httpx.Timeout(
connect=5.0, # TCP + TLS
read=30.0, # waiting for response bytes
write=10.0, # sending request body
pool=2.0, # acquiring a connection from the pool
)
client = httpx.Client(timeout=httpx.Timeout(connect=5, read=30, write=10, pool=2))
# Shortcut: single number applies to all four
client = httpx.Client(timeout=10)
client = httpx.Client(timeout=None) # NO TIMEOUT — never do this in prodThe default in httpx is 5 seconds; even so, be explicit. Without a timeout, a stalled server hangs your client forever.
8. Retries with tenacity
uv add tenacityfrom tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
@retry(
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=0.5, max=10),
reraise=True,
)
def fetch(url):
with httpx.Client(timeout=10) as client:
r = client.get(url)
r.raise_for_status()
return r.json()Exponential backoff: wait 0.5s, 1s, 2s, 4s, 8s (capped). reraise=True propagates the last exception after exhaustion (otherwise it raises RetryError).
Async retries
tenacity supports AsyncRetrying:
from tenacity import AsyncRetrying, stop_after_attempt, wait_exponential
async def fetch_async(url, client):
async for attempt in AsyncRetrying(stop=stop_after_attempt(5), wait=wait_exponential()):
with attempt:
r = await client.get(url)
r.raise_for_status()
return r.json()Or @retry on async def directly (auto-detects async).
Idempotency
Only retry idempotent requests. GET, PUT, DELETE are safe. POST often isn't — retrying a "create order" can charge a card twice.
For "exactly once" semantics on POST, use an idempotency key (random UUID per logical operation, sent in a header; server deduplicates).
9. httpx event hooks
def log_request(req): print(f"-> {req.method} {req.url}")
def log_response(resp):
req = resp.request
print(f"<- {resp.status_code} {req.url}")
client = httpx.Client(event_hooks={"request": [log_request], "response": [log_response]})Add hooks for logging, metrics, OpenTelemetry instrumentation. Less invasive than wrapping every call.
10. Proxies, SSL, advanced
client = httpx.Client(proxies="http://proxy.local:8080")
client = httpx.Client(verify=False) # disable SSL (NEVER in prod)
client = httpx.Client(verify="/path/to/ca.pem", cert=("/path/to/client.crt", "/path/to/client.key"))
client = httpx.Client(transport=httpx.HTTPTransport(retries=2))For SOCKS5: pip install httpx[socks] then proxies="socks5://...".
11. Cookies / sessions
with httpx.Client() as client:
client.cookies.set("session", "abc")
client.get("/dashboard") # sends the cookieClient persists cookies across calls.
12. Testing — MockTransport
import httpx
import pytest
def app(request: httpx.Request) -> httpx.Response:
if request.url.path == "/users/1":
return httpx.Response(200, json={"id": 1, "name": "Ada"})
return httpx.Response(404)
transport = httpx.MockTransport(app)
def test_fetch_user():
with httpx.Client(transport=transport, base_url="https://api.example.com") as client:
r = client.get("/users/1")
assert r.status_code == 200
assert r.json() == {"id": 1, "name": "Ada"}No network. No mock.patch. Test the real client + transport pipeline.
For more complex stubbing, look at respx (uv add respx) — pattern-matched route mocks with assertions.
13. Retry semantics
What to retry
- Network errors (
ConnectError,ReadTimeout,RemoteProtocolError). - 502 / 503 / 504.
- 429 with
Retry-After(respect the header).
What NOT to retry
- 4xx (except 408, 429) — these mean "you're wrong"; retrying won't help.
- Non-idempotent POST/PATCH without idempotency key.
- Long-running streaming responses (resume instead).
Respect Retry-After
from tenacity import retry, stop_after_attempt, wait_chain, wait_fixed
def respect_retry_after(retry_state):
exc = retry_state.outcome.exception()
if isinstance(exc, httpx.HTTPStatusError):
if v := exc.response.headers.get("Retry-After"):
return int(v)
return 1 # fallback
@retry(stop=stop_after_attempt(5), wait=respect_retry_after)
def fetch(url): ...For LLM APIs, this matters a lot — they all use Retry-After for rate limits.
14. Circuit breaker pattern
After N consecutive failures, "open" the circuit and fail fast for a cool-down period:
import time
from collections import deque
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_seconds=30):
self.failures = 0
self.opened_at = None
self.failure_threshold = failure_threshold
self.recovery_seconds = recovery_seconds
def call(self, fn, *args, **kwargs):
if self.opened_at and time.time() - self.opened_at < self.recovery_seconds:
raise RuntimeError("circuit open")
try:
result = fn(*args, **kwargs)
except Exception:
self.failures += 1
if self.failures >= self.failure_threshold:
self.opened_at = time.time()
raise
else:
self.failures = 0
self.opened_at = None
return resultFor real services use pybreaker or service-mesh features. Circuit breakers protect downstream services from a thundering herd of retries.
15. Worked example: fetch many JSON endpoints, with retries
import asyncio
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
class FetchError(Exception): pass
@retry(
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=0.5, max=8),
reraise=True,
)
async def fetch_one(client: httpx.AsyncClient, url: str) -> dict:
r = await client.get(url, timeout=15)
r.raise_for_status()
return r.json()
async def fetch_many(urls: list[str], *, concurrency: int = 20) -> dict[str, dict]:
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(http2=True) as client:
async def bound(u):
async with sem:
try:
return u, await fetch_one(client, u)
except Exception as e:
return u, {"error": str(e)}
results = await asyncio.gather(*(bound(u) for u in urls))
return dict(results)Reused client, bounded concurrency, exponential backoff, per-URL error capture. Production-grade outbound HTTP in ~25 lines.
Hands-on lab (1.5 hours)
- Build a
Clientthat fetches 50 URLs sequentially; measure time. - Switch to
AsyncClientwithasyncio.gather; measure speedup. - Add a
Semaphoreto cap concurrency at 10. - Wrap
fetch_onewith@retry; simulate failures with a flaky test server (orrespx). - Stream a 100 MB download to disk without holding it in memory.
- Test with
httpx.MockTransport; assert request method, URL, body. - Implement a tiny rate-limit-aware retry that respects
Retry-After.
Common pitfalls
- Creating a
Clientper request (slow; no pool). - No timeout (
timeout=None) — eternal hangs on stalls. - Retrying non-idempotent POSTs.
- Disabling SSL verification "to make it work."
- Mixing sync
httpxin async code. - Not closing the client (
withblock orawait client.aclose()). - Logging full request bodies — leaks tokens / PII.
Self-check
- Why use a long-lived
Client? - Difference between
ClientandAsyncClient? - When NOT to retry?
- How to stream a large response?
- What does
MockTransportgive you?
References
- httpx docs: https://www.python-httpx.org/.
- tenacity docs: https://tenacity.readthedocs.io/.
- respx: https://lundberg.github.io/respx/.
- Stripe API guide on idempotency keys.
- "The Failure of Programs", Sam Newman (microservices resilience patterns).
Sign in to save your progress and earn badges.