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.