httpx — sync and async HTTP clients

Connection pools, timeouts, retries with tenacity, and the redirect and TLS pitfalls to plan for.

🌐 Module 8 8 min read Not started

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

  1. Use httpx synchronously and asynchronously.
  2. Reuse Client / AsyncClient for connection pooling.
  3. Stream large responses without loading into memory.
  4. Apply retries / timeouts / backoff with tenacity.
  5. Test HTTP calls with MockTransport.

1. Quick start — httpx is requests's spiritual successor

powershell
uv add httpx
python
import 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/5xx

API 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:

python
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:

python
@asynccontextmanager
async def lifespan(app):
    app.state.http = httpx.AsyncClient(timeout=10)
    yield
    await app.state.http.aclose()

3. Async client

python
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:

python
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

python
# 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):

python
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:

python
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)

python
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

python
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

python
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 prod

The default in httpx is 5 seconds; even so, be explicit. Without a timeout, a stalled server hangs your client forever.


8. Retries with tenacity

powershell
uv add tenacity
python
from 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:

python
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

python
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

python
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

python
with httpx.Client() as client:
    client.cookies.set("session", "abc")
    client.get("/dashboard")           # sends the cookie

Client persists cookies across calls.


12. Testing — MockTransport

python
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

python
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:

python
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 result

For 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

python
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)

  1. Build a Client that fetches 50 URLs sequentially; measure time.
  2. Switch to AsyncClient with asyncio.gather; measure speedup.
  3. Add a Semaphore to cap concurrency at 10.
  4. Wrap fetch_one with @retry; simulate failures with a flaky test server (or respx).
  5. Stream a 100 MB download to disk without holding it in memory.
  6. Test with httpx.MockTransport; assert request method, URL, body.
  7. Implement a tiny rate-limit-aware retry that respects Retry-After.

Common pitfalls

  1. Creating a Client per request (slow; no pool).
  2. No timeout (timeout=None) — eternal hangs on stalls.
  3. Retrying non-idempotent POSTs.
  4. Disabling SSL verification "to make it work."
  5. Mixing sync httpx in async code.
  6. Not closing the client (with block or await client.aclose()).
  7. Logging full request bodies — leaks tokens / PII.

Self-check

  1. Why use a long-lived Client?
  2. Difference between Client and AsyncClient?
  3. When NOT to retry?
  4. How to stream a large response?
  5. What does MockTransport give you?

References

Sign in to save your progress and earn badges.