Skip to content

Cookies, redirects, proxy & TLS

async with HTTPClient(
    base_url="https://api.example.com/",
    cookie_store=True,  # in-memory cookie jar, sent automatically on subsequent requests
    follow_redirects=True,  # default
    max_redirects=10,  # None (default) leaves pyreqwest's own limit in place
    proxy="http://localhost:8080",
    no_proxy=["localhost", "internal.example.com"],  # hosts that skip the proxy
) as client:
    ...

Unlike timeout and auth (see Authentication), none of these three take a per-call override — pyreqwest's RequestBuilder has no .follow_redirects()/.max_redirects()/.proxy()/ .default_cookie_store() at all, only its ClientBuilder does. That's not a gap lothc could close: redirect policy, proxy, and the cookie jar are all client/connection-level constructs in the underlying Rust reqwest crate itself, not per-request ones — the proxy decides which TCP connection gets opened, the cookie jar is shared state, and the redirect policy governs the transport loop before a response ever reaches request-level code. If one call genuinely needs different redirect/proxy/cookie behavior than the rest, build a second client with that setting rather than looking for a per-verb kwarg.

no_proxy entries use the same format as the NO_PROXY environment variable. It only narrows proxy, so passing it without proxy raises ValueError.

User-Agent and HTTP/2

async with HTTPClient(
    base_url="https://api.example.com/",
    user_agent="my-app/1.2",  # sent on every request; a per-request `headers=` still overrides it
    http2=True,  # negotiate HTTP/2 where the server supports it
) as client:
    ...

Without user_agent, requests identify as python-lothc/<version>.

http2=True negotiates HTTP/2 during the TLS handshake (ALPN) and falls back to HTTP/1.1 when the server doesn't offer it, so it's safe to turn on for a mixed set of servers. A plain http:// connection stays on HTTP/1.1.

TLS & mTLS

async with HTTPClient(
    base_url="https://internal-api.example.com/",
    root_certificates=[Path("internal-ca.pem").read_bytes()],  # trust a custom/internal CA
    identity_pem=Path("client-identity.pem").read_bytes(),  # mTLS: cert + private key, one PEM
    min_tls_version="TLSv1.2",
    max_tls_version="TLSv1.3",
    https_only=True,  # refuse plain HTTP entirely
) as client:
    ...

root_certificates takes any number of PEM-encoded certificates — each one is trusted in addition to (not instead of) the system's own root store. identity_pem is a single PEM buffer containing both the client certificate and its private key concatenated, exactly as reqwest's own Identity::from_pem expects.

There's also danger_accept_invalid_certs: bool = False, which disables certificate validation entirely — insecure, and only ever appropriate against a local/test endpoint you control, never in production.

Like timeout, these are all client-level only — set once on the client, no per-call override, since a TLS/connection identity belongs to the underlying connection, not a single request.

DNS overrides, source address and keepalive

async with HTTPClient(
    resolve={"api.example.com": "10.0.0.5"},  # skip DNS for this host; the URL's port still applies
    local_address="10.0.0.2",  # the source IP connections come from
    tcp_keepalive=60.0,  # seconds before TCP keepalive probes start on an idle connection
) as client:
    ...

resolve is handy for pointing a real hostname at a staging box or a local server without editing /etc/hosts; TLS still validates the certificate against the hostname in the URL.

Connection pooling

async with HTTPClient(
    base_url="https://api.example.com/",
    connect_timeout=5.0,  # bounds only the TCP connect phase, separate from `timeout`
    read_timeout=60.0,  # max idle gap between body chunks — the stall detector for `sse()`
    max_connections=50,
    pool_idle_timeout=30.0,  # None (default) leaves pyreqwest's own 90s default in place
    pool_max_idle_per_host=10,
    pool_timeout=2.0,  # max wait for a free connection slot
) as client:
    ...

connect_timeout is distinct from timeout — it only bounds the initial TCP connect, not the whole request, so it's useful for "fail fast on a dead host" without capping how long a slow (but alive) download is allowed to take.

read_timeout is distinct from both — it bounds the idle gap between two consecutive body chunks, not the whole request. A body that keeps trickling never trips it; one that stalls does. That makes it the one timeout that's actually meaningful for an open-ended sse() stream, which the total timeout deliberately doesn't apply to — see SSE → Timeouts.