361 lines
12 KiB
Markdown
361 lines
12 KiB
Markdown
|
|
# httpx2 — Production Defaults
|
||
|
|
|
||
|
|
> **Source**: [pydantic/httpx2](https://github.com/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]",
|
||
|
|
]
|
||
|
|
```
|
||
|
|
|
||
|
|
| Extra | What it enables | Why it's mandatory |
|
||
|
|
|-------|----------------|--------------------|
|
||
|
|
| `http2` | HTTP/2 multiplexing via `h2` | Single TCP connection handles concurrent requests; eliminates head-of-line blocking |
|
||
|
|
| `brotli` | Brotli content decoding (`br`) | ~20% smaller payloads than gzip for text/JSON |
|
||
|
|
| `zstd` | Zstandard content decoding | Faster decompression than brotli at similar ratios; stdlib in Python ≥ 3.14 |
|
||
|
|
| `socks` | SOCKS5 proxy support via `socksio` | Install 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
|
||
|
|
|
||
|
|
| Setting | Library default | Our default | Why |
|
||
|
|
|---------|----------------|-------------|-----|
|
||
|
|
| `http2` | `False` | **`True`** | HTTP/2 multiplexing is strictly superior for any modern API |
|
||
|
|
| `max_connections` | `100` | `200` | Headroom for fan-out; prevents pool exhaustion under load |
|
||
|
|
| `max_keepalive_connections` | `20` | `40` | Keeps warm connections alive; fewer TLS handshakes |
|
||
|
|
| `keepalive_expiry` | `5.0s` | `30.0s` | 5s is too aggressive — kills connections between burst requests |
|
||
|
|
| `Timeout(5.0)` uniform | `5.0` all | Split | Uniform 5s is too tight for reads, too loose for connects |
|
||
|
|
| `read` timeout | `5.0` | `30.0` | Slow APIs and streaming need breathing room |
|
||
|
|
| `pool` timeout | `5.0` | `10.0` | Explicit — hitting this means `max_connections` needs raising |
|
||
|
|
| `TCP_NODELAY` | off | **on** | Eliminates Nagle's 40ms coalescing delay for small payloads |
|
||
|
|
| `retries` | `0` | `3` | Retries on `ConnectError`/`ConnectTimeout` only — safe and resilient |
|
||
|
|
| `follow_redirects` | `False` | **`True`** | Most 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:
|
||
|
|
|
||
|
|
| Scenario | Override |
|
||
|
|
|----------|----------|
|
||
|
|
| LLM streaming endpoints | `timeout=httpx2.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)` — no read timeout on streaming |
|
||
|
|
| Single-host API with low concurrency | `limits=httpx2.Limits(max_connections=50, max_keepalive_connections=20, keepalive_expiry=60.0)` |
|
||
|
|
| Ephemeral short-lived requests | `keepalive_expiry=5.0` — don't hold connections |
|
||
|
|
| Unix domain sockets | `httpx2.AsyncHTTPTransport(uds="/path/to/socket", ...)` |
|
||
|
|
| mTLS / client certs | Pass `verify=ssl_ctx` with `ctx.load_cert_chain(certfile=...)` |
|
||
|
|
| SOCKS proxy | `httpx2[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`
|
||
|
|
|
||
|
|
| Parameter | Type | Library Default | **Our Default** |
|
||
|
|
|-----------|------|-----------------|-----------------|
|
||
|
|
| `http1` | `bool` | `True` | `True` |
|
||
|
|
| `http2` | `bool` | `False` | **`True`** |
|
||
|
|
| `verify` | `ssl.SSLContext \| str \| bool` | `True` | `True` |
|
||
|
|
| `cert` | `CertTypes \| None` | `None` | `None` |
|
||
|
|
| `proxy` | `str \| Proxy \| None` | `None` | `None` |
|
||
|
|
| `mounts` | `dict[str, Transport]` | `None` | `None` |
|
||
|
|
| `timeout` | `Timeout \| float \| None` | `Timeout(5.0)` | **Split: 5/30/10/10** |
|
||
|
|
| `limits` | `Limits` | `Limits(100, 20, 5.0)` | **`Limits(200, 40, 30.0)`** |
|
||
|
|
| `follow_redirects` | `bool` | `False` | **`True`** |
|
||
|
|
| `max_redirects` | `int` | `20` | `20` |
|
||
|
|
| `event_hooks` | `dict` | `{}` | **Wire logging** |
|
||
|
|
| `base_url` | `str` | `""` | Set for single-API clients |
|
||
|
|
| `trust_env` | `bool` | `True` | `True` |
|
||
|
|
| `default_encoding` | `str \| Callable` | `"utf-8"` | `"utf-8"` |
|
||
|
|
|
||
|
|
### `httpx2.AsyncHTTPTransport` / `httpx2.HTTPTransport`
|
||
|
|
|
||
|
|
| Parameter | Type | Library Default | **Our Default** |
|
||
|
|
|-----------|------|-----------------|-----------------|
|
||
|
|
| `http1` | `bool` | `True` | `True` |
|
||
|
|
| `http2` | `bool` | `False` | **`True`** |
|
||
|
|
| `retries` | `int` | `0` | **`3`** |
|
||
|
|
| `limits` | `Limits` | `Limits(100, 20, 5.0)` | **`Limits(200, 40, 30.0)`** |
|
||
|
|
| `uds` | `str \| None` | `None` | `None` |
|
||
|
|
| `local_address` | `str \| None` | `None` | `None` |
|
||
|
|
| `socket_options` | `Iterable[SOCKET_OPTION]` | `None` | **`[TCP_NODELAY]`** |
|
||
|
|
| `proxy` | `str \| Proxy \| None` | `None` | `None` |
|
||
|
|
|
||
|
|
### `httpx2.Timeout`
|
||
|
|
|
||
|
|
| Parameter | Library Default | **Our Default** |
|
||
|
|
|-----------|-----------------|-----------------|
|
||
|
|
| `connect` | `5.0` | `5.0` |
|
||
|
|
| `read` | `5.0` | **`30.0`** |
|
||
|
|
| `write` | `5.0` | **`10.0`** |
|
||
|
|
| `pool` | `5.0` | **`10.0`** |
|
||
|
|
|
||
|
|
### `httpx2.Limits`
|
||
|
|
|
||
|
|
| Parameter | Library Default | **Our Default** |
|
||
|
|
|-----------|-----------------|-----------------|
|
||
|
|
| `max_connections` | `100` | **`200`** |
|
||
|
|
| `max_keepalive_connections` | `20` | **`40`** |
|
||
|
|
| `keepalive_expiry` | `5.0` | **`30.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]`.
|