Back to Oh My Openagent

httpx2 — Production Defaults

packages/shared-skills/skills/programming/references/python/httpx2-optimization.md

4.5.1212.0 KB
Original Source

httpx2 — Production Defaults

Source: pydantic/httpx2 — next-generation HTTP client for Python 3, continuation of HTTPX under Pydantic stewardship.

Rule: Every network request MUST use httpx2. ALL optimizations below are ON by default — HTTP/2, brotli+zstd, tuned connection pool, fine-grained timeouts, transport retries, TCP_NODELAY. This is the baseline, not a stretch goal. A bare httpx2.AsyncClient() is a bug.


1. Installation — all extras, always

toml
# pyproject.toml
dependencies = [
    "httpx2[http2,brotli,zstd]",
]
ExtraWhat it enablesWhy it's mandatory
http2HTTP/2 multiplexing via h2Single TCP connection handles concurrent requests; eliminates head-of-line blocking
brotliBrotli content decoding (br)~20% smaller payloads than gzip for text/JSON
zstdZstandard content decodingFaster decompression than brotli at similar ratios; stdlib in Python ≥ 3.14
socksSOCKS5 proxy support via socksioInstall only if you route through SOCKS proxies

All three core extras (http2,brotli,zstd) are non-negotiable. Omitting any is leaving performance on the table.


2. The canonical defaults — ALL ON

These are not "optimizations to consider". These are the correct defaults that every httpx2 client must use.

python
import socket
import httpx2

# ── These are the STANDARD values. Use them verbatim. ──

LIMITS = httpx2.Limits(
    max_connections=200,           # library default 100 is too conservative
    max_keepalive_connections=40,  # library default 20 wastes reconnects
    keepalive_expiry=30.0,         # library default 5s kills warm connections too fast
)

TIMEOUT = httpx2.Timeout(
    connect=5.0,    # TCP + TLS handshake budget
    read=30.0,      # time to receive a response chunk
    write=10.0,     # time to send a request chunk
    pool=10.0,      # time to acquire a connection from pool
)

SOCKET_OPTIONS: list[tuple[int, int, int]] = [
    (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),   # disable Nagle — no 40ms delay
]

Why each knob is set this way

SettingLibrary defaultOur defaultWhy
http2FalseTrueHTTP/2 multiplexing is strictly superior for any modern API
max_connections100200Headroom for fan-out; prevents pool exhaustion under load
max_keepalive_connections2040Keeps warm connections alive; fewer TLS handshakes
keepalive_expiry5.0s30.0s5s is too aggressive — kills connections between burst requests
Timeout(5.0) uniform5.0 allSplitUniform 5s is too tight for reads, too loose for connects
read timeout5.030.0Slow APIs and streaming need breathing room
pool timeout5.010.0Explicit — hitting this means max_connections needs raising
TCP_NODELAYoffonEliminates Nagle's 40ms coalescing delay for small payloads
retries03Retries on ConnectError/ConnectTimeout only — safe and resilient
follow_redirectsFalseTrueMost APIs redirect; failing on 3xx is wrong default behavior

3. Factory functions — the ONE correct way to create clients

Copy this into your project. This is the canonical pattern.

python
"""httpx2 client factory. Always use create_client() / create_async_client()."""

from __future__ import annotations

import socket
import typing

import httpx2

_LIMITS = httpx2.Limits(
    max_connections=200,
    max_keepalive_connections=40,
    keepalive_expiry=30.0,
)

_TIMEOUT = httpx2.Timeout(
    connect=5.0,
    read=30.0,
    write=10.0,
    pool=10.0,
)

_SOCKET_OPTIONS: list[tuple[int, int, int]] = [
    (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
]


def create_async_client(
    *,
    base_url: str = "",
    http2: bool = True,
    retries: int = 3,
    limits: httpx2.Limits = _LIMITS,
    timeout: httpx2.Timeout = _TIMEOUT,
    headers: dict[str, str] | None = None,
    event_hooks: dict[str, list[typing.Callable[..., typing.Any]]] | None = None,
    **kwargs: typing.Any,
) -> httpx2.AsyncClient:
    transport = httpx2.AsyncHTTPTransport(
        http2=http2,
        retries=retries,
        limits=limits,
        socket_options=_SOCKET_OPTIONS,
    )
    return httpx2.AsyncClient(
        transport=transport,
        timeout=timeout,
        base_url=base_url,
        headers=headers or {},
        event_hooks=event_hooks or {},
        follow_redirects=True,
        **kwargs,
    )


def create_client(
    *,
    base_url: str = "",
    http2: bool = True,
    retries: int = 3,
    limits: httpx2.Limits = _LIMITS,
    timeout: httpx2.Timeout = _TIMEOUT,
    headers: dict[str, str] | None = None,
    event_hooks: dict[str, list[typing.Callable[..., typing.Any]]] | None = None,
    **kwargs: typing.Any,
) -> httpx2.Client:
    transport = httpx2.HTTPTransport(
        http2=http2,
        retries=retries,
        limits=limits,
        socket_options=_SOCKET_OPTIONS,
    )
    return httpx2.Client(
        transport=transport,
        timeout=timeout,
        base_url=base_url,
        headers=headers or {},
        event_hooks=event_hooks or {},
        follow_redirects=True,
        **kwargs,
    )

Usage:

python
# Async — the common case
async with create_async_client(base_url="https://api.example.com") as client:
    r = await client.get("/users")

# Sync
with create_client() as client:
    r = client.get("https://api.example.com/health")

If you are NOT using this factory pattern, you are doing it wrong. A bare httpx2.AsyncClient() leaves HTTP/2 off, retries off, TCP_NODELAY off, keepalive too short, and timeouts too uniform.


4. Special case overrides

The factory defaults cover 95% of use cases. Override only when you have a specific reason:

ScenarioOverride
LLM streaming endpointstimeout=httpx2.Timeout(connect=10.0, read=None, write=10.0, pool=10.0) — no read timeout on streaming
Single-host API with low concurrencylimits=httpx2.Limits(max_connections=50, max_keepalive_connections=20, keepalive_expiry=60.0)
Ephemeral short-lived requestskeepalive_expiry=5.0 — don't hold connections
Unix domain socketshttpx2.AsyncHTTPTransport(uds="/path/to/socket", ...)
mTLS / client certsPass verify=ssl_ctx with ctx.load_cert_chain(certfile=...)
SOCKS proxyhttpx2[socks], proxy="socks5://..."

5. Event hooks — always wire observability

This is not optional. Every production client should log requests.

python
import time
import logging

logger = logging.getLogger(__name__)

async def log_request(request: httpx2.Request) -> None:
    request.extensions["request_start"] = time.perf_counter()

async def log_response(response: httpx2.Response) -> None:
    start = response.request.extensions.get("request_start", 0)
    elapsed = time.perf_counter() - start
    logger.info(
        "HTTP %s %s → %d (%.3fs, %s)",
        response.request.method,
        response.request.url,
        response.status_code,
        elapsed,
        response.http_version,
    )

# Sync versions for Client
def log_request_sync(request: httpx2.Request) -> None:
    request.extensions["request_start"] = time.perf_counter()

def log_response_sync(response: httpx2.Response) -> None:
    start = response.request.extensions.get("request_start", 0)
    elapsed = time.perf_counter() - start
    logger.info(
        "HTTP %s %s → %d (%.3fs, %s)",
        response.request.method,
        response.request.url,
        response.status_code,
        elapsed,
        response.http_version,
    )

For auto raise_for_status():

python
async def raise_on_error(response: httpx2.Response) -> None:
    response.raise_for_status()

6. Verification script — confirm your setup is fully optimized

Run this against your target endpoint to verify (not decide) that all optimizations are active:

python
"""Verify httpx2 is fully optimized against a target endpoint."""

from __future__ import annotations

import socket
import time

import anyio
import httpx2


TARGET_URL = "https://api.example.com/health"
ITERATIONS = 30


async def bench(label: str, client: httpx2.AsyncClient, url: str, n: int) -> float:
    for _ in range(3):  # warmup
        await client.get(url)
    start = time.perf_counter()
    for _ in range(n):
        r = await client.get(url)
        assert r.status_code == 200
    elapsed = time.perf_counter() - start
    avg_ms = (elapsed / n) * 1000
    print(f"  {label}: {avg_ms:.1f}ms avg ({n} reqs in {elapsed:.2f}s)")
    return avg_ms


async def main() -> None:
    results: dict[str, float] = {}

    # BAD: bare defaults (this is what we're proving is worse)
    async with httpx2.AsyncClient() as c:
        results["BAD-bare-defaults"] = await bench("BAD-bare-defaults", c, TARGET_URL, ITERATIONS)

    # GOOD: full production defaults (this is what we always use)
    limits = httpx2.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=30.0)
    timeout = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=10.0)
    transport = httpx2.AsyncHTTPTransport(
        http2=True, retries=3, limits=limits,
        socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)],
    )
    async with httpx2.AsyncClient(transport=transport, timeout=timeout, follow_redirects=True) as c:
        results["GOOD-full-production"] = await bench("GOOD-full-production", c, TARGET_URL, ITERATIONS)

    print("\n--- Proof ---")
    baseline = results["BAD-bare-defaults"]
    for label, avg in results.items():
        delta = ((avg - baseline) / baseline) * 100
        print(f"  {label}: {avg:.1f}ms ({delta:+.1f}% vs bare)")


if __name__ == "__main__":
    anyio.run(main)

7. Quick reference — all knobs

httpx2.AsyncClient / httpx2.Client

ParameterTypeLibrary DefaultOur Default
http1boolTrueTrue
http2boolFalseTrue
verifyssl.SSLContext | str | boolTrueTrue
certCertTypes | NoneNoneNone
proxystr | Proxy | NoneNoneNone
mountsdict[str, Transport]NoneNone
timeoutTimeout | float | NoneTimeout(5.0)Split: 5/30/10/10
limitsLimitsLimits(100, 20, 5.0)Limits(200, 40, 30.0)
follow_redirectsboolFalseTrue
max_redirectsint2020
event_hooksdict{}Wire logging
base_urlstr""Set for single-API clients
trust_envboolTrueTrue
default_encodingstr | Callable"utf-8""utf-8"

httpx2.AsyncHTTPTransport / httpx2.HTTPTransport

ParameterTypeLibrary DefaultOur Default
http1boolTrueTrue
http2boolFalseTrue
retriesint03
limitsLimitsLimits(100, 20, 5.0)Limits(200, 40, 30.0)
udsstr | NoneNoneNone
local_addressstr | NoneNoneNone
socket_optionsIterable[SOCKET_OPTION]None[TCP_NODELAY]
proxystr | Proxy | NoneNoneNone

httpx2.Timeout

ParameterLibrary DefaultOur Default
connect5.05.0
read5.030.0
write5.010.0
pool5.010.0

httpx2.Limits

ParameterLibrary DefaultOur Default
max_connections100200
max_keepalive_connections2040
keepalive_expiry5.030.0

Async backend (httpcore2)

httpcore2 uses anyio by default (works with both asyncio and trio). No extra config needed if you're already on the anyio stack. For trio, install httpcore2[trio].