Overview
The HTTP client abstraction provides a runtime-agnostic interface for making HTTP requests. It’s primarily used for:
- Media uploads to WhatsApp servers
- Media downloads (with streaming support)
- Fetching metadata and authentication tokens
The client supports both buffered and streaming responses for efficient handling of large files.
HttpClient Trait
MaybeSendSync is Send + Sync on native targets and carries no bounds on wasm32. Custom HttpClient implementations backed by !Send browser fetch handles now compile on the wasm port. On native, Arc<dyn HttpClient> remains Send + Sync as before.
supports_upload_streaming / execute_upload were added in v0.6 to back Client::upload_stream. Both have safe defaults (returning false / an error), so existing custom HttpClient implementations keep compiling — implement them only if you want constant-memory uploads through your client. UreqHttpClient implements both. UploadBody is Box<dyn std::io::Read + Send>.
Return Ok for every completed exchange, even a non-2xx status. Reserve Err for a request that never completed — DNS failure, connection refused, a TLS error, or a timeout.execute and execute_upload have one more Err case. If you read a 2xx body past your declared cap (max_body_bytes on UreqHttpClient), fail that read — a truncated payload must never look like a complete one. execute_streaming doesn’t raise this error: its reader just reaches EOF at the cap, and a downstream integrity check (MAC or SHA) catches the truncation instead. See Internal Implementation for how UreqHttpClient implements both halves of this.Read status_code off the Ok response to decide what to do next. Download and upload both use it to refresh a stale media-auth token (401/403) and to move to the next CDN host on other errors like 5xx — see download’s retry and URL re-derivation and upload’s retry on auth errors. Download has one case upload doesn’t: it treats an expired URL (404/410) the same as an auth error and re-derives it. If you map 4xx/5xx to Err instead, you hide the status from all of this, and every retry repeats the same stale auth token.Some HTTP crates default the wrong way. ureq’s http_status_as_error turns non-2xx into an Err unless you disable it, which is why UreqHttpClient disables it on every request. If you build a custom client on ureq or a similar library, disable that default yourself. MockHttpClient below shows the shape a compliant implementation should have: Err only when there’s no response to give at all.
Methods
supports_streaming
Returns whether this HTTP client supports synchronous streaming downloads via execute_streaming. The default implementation returns false. When this returns false, download_to_writer automatically falls back to a buffered download using execute instead.
Returns:
true if execute_streaming is implemented (e.g., UreqHttpClient)
false (default) if streaming is not supported
execute
Executes an HTTP request and buffers the entire response body.
Parameters:
request: HttpRequest - The request to execute
Returns:
HttpResponse for any completed exchange — a non-2xx status_code is still Ok; read it to classify the response
anyhow::Error when the request never completed (a transport-level failure), or when a 2xx body exceeds your configured cap
Example:
execute_streaming
Executes an HTTP request and returns a streaming reader over the response body.
Important: This is a synchronous method that must be called from within tokio::task::spawn_blocking.
Parameters:
request: HttpRequest - The request to execute
Returns:
StreamingHttpResponse for any completed exchange — a non-2xx status_code is still Ok
anyhow::Error on a transport-level failure, or if streaming is not supported. A body past your cap is not one of these cases — the reader reaches EOF early instead; see the contract note above
Example:
resource_report
Best-effort per-session footprint of this client: idle connection-pool buffers plus any in-flight download/media buffering the implementation can see. Defaulted to None (“not reported”) — a client that can introspect its pool overrides it.
Returns:
Some(HttpResourceReport) with any subset of pool_connections, pool_buffer_bytes, inflight_bytes filled in (each Option<u64>)
None (default) if the client doesn’t report
Feeds into Client::resource_report(). UreqHttpClient overrides this (below) with an estimate for its default agent’s idle pool; media downloads are a real transient-RAM source, so even a coarse estimate is worth reporting.
Data Structures
HttpRequest
Represents an HTTP request with headers and optional body.
Constructors
Builder Methods
HttpResponse
Represents an HTTP response with buffered body.
Methods
StreamingHttpResponse
Represents an HTTP response with streaming body reader.
Usage:
UreqHttpClient
The default HTTP client implementation using the ureq crate (v3.4) for synchronous HTTP requests.
Features
- Blocking I/O - Uses synchronous ureq, wrapped in
tokio::task::spawn_blocking
- Connection pooling - Shares a
ureq::Agent across requests for connection reuse
- Streaming support - Implements efficient streaming downloads
- Simple API - Minimal configuration required
- Thread-safe - Implements
Clone for easy sharing (cloning the Agent is cheap)
- TLS via rustls - Uses rustls for TLS, with optional
danger-skip-tls-verify for testing
Creating a client
with_agent
Creates a client with a pre-configured ureq::Agent. This lets you configure proxy support, custom TLS, timeouts, or any other agent-level settings externally.
This is the primary extension point for customizing HTTP behavior — for example, routing media uploads and downloads through a proxy, or using custom CA certificates.
Parameters:
agent - A pre-configured ureq::Agent
Example — proxy support:
See custom backends — proxy and custom TLS for a complete guide.
The version fetch does not pool a connection
Client::connect() fetches a version update unless you’ve set with_version or the cached version is under 24h old. The source depends on the target: everywhere except wasm32, it’s https://web.whatsapp.com/sw.js; that request sends Connection: close, so ureq drops the connection at cleanup instead of returning it to the pool. Previously this fetch left one idle TLS connection resident for the rest of the session, even for a session that never touched media, measured at roughly 88 KiB of RssAnon per session. This change eliminates that idle-connection cost. It doesn’t guarantee zero residual state, though — a shared agent can still retain a small TLS session-resumption ticket after the handshake (see below).
On wasm32, sw.js can’t be reached from a page. It only answers 200 to a Sec-Fetch-Site: none request. That’s a forbidden header name a script can’t set, so a browser drops it and gets a 400 instead. No response from web.whatsapp.com carries Access-Control-Allow-Origin either, so a request that got past the header would still fail cross-origin. So the wasm build instead reads the same build revision from https://connect.facebook.net/en_US/sdk.js — the Facebook JS SDK bundle. Meta serves it with Access-Control-Allow-Origin: * for cross-origin loading, with no fetch-metadata gate. The number is Meta’s shared www build revision. It’s identical across web.whatsapp.com, facebook.com, instagram.com, and messenger.com, so it’s the same revision sw.js would have reported. The request adds no headers of its own. There’s no fallback between the two sources — whichever one applies to the target is the only one tried, never the other. What happens after a failure there differs by target, though: on native, it surfaces as ConnectError::Version. On wasm32, as of PR #1360, it usually doesn’t — the connection survives on the version the device already holds, and Event::Connected reports the fallback via app_version_fallback instead. See Connected for when that fallback fires and when a wasm32 failure still reaches ConnectError::Version.
resource_report() reflects the same change for the default client: a request carrying Connection: close is treated as non-pooling, so a session using UreqHttpClient::new() whose only HTTP traffic is the version fetch reports an empty pool (pool_connections: Some(0), pool_buffer_bytes: Some(0)) instead of latching onto the 96 KiB cap described below after its first request. A client created with UreqHttpClient::with_agent(...) continues to report None because its pool configuration is opaque. Media requests are untouched by this — the pool still exists there to make the next range request against the same CDN host cheap.
Sharing one client across many sessions
If your process runs many WhatsApp sessions, build one UreqHttpClient. Wrap it in an Arc and pass it to every builder with BotBuilder::with_http_client_arc instead of constructing one per session. UreqHttpClient is also Clone, and cloning it shares the underlying ureq::Agent and therefore its connection pool — so with_http_client(shared_http_client.clone()) shares just as well. Reach for with_http_client_arc instead once the client is already type-erased to Arc<dyn HttpClient>, since nothing lets that reach the by-value setter at all.
This is worth doing for a process with pooled HTTP traffic — media, in practice. An idle session retains no idle connection-pool buffers (on the non-wasm32 targets UreqHttpClient runs on, the version fetch sends Connection: close, so it pools no connection either way — though a shared agent can still hold onto a small TLS session-resumption ticket, see above), but a session that has transferred media retains its own connection-pool buffers — on the order of tens of KiB of live heap over plain HTTP, more over TLS — for as long as its UreqHttpClient lives. Building one per bot pays that cost once per session; sharing collapses the whole fleet onto one pool.
Does sharing serialize requests? No. ureq::Agent holds its pool lock only across checkout, never across the request itself, so concurrent requests through one shared client run concurrently — each still occupies its own spawn_blocking thread, exactly as it would with a client per bot.
What sharing does change: the idle pool is capped per agent, not per bot. UreqHttpClient::new()’s default agent retains 3 idle connections and 2 per host — sized for one bot’s traffic, not a fleet’s. Share it across several concurrent workers and they now contend over that one small pool; measured at 8 concurrent workers, a shared default agent reused ~80% of connections versus ~95% with one client each. If you’re sharing across meaningful concurrency, size a ureq::Agent for the fleet with with_agent before sharing it, raising both max_idle_connections and max_idle_connections_per_host. Raising only the global cap can still leave you bottlenecked: media traffic concentrates on a small, fixed set of CDN hosts — WhatsApp’s default media route is two, a primary and a fallback — so whichever of those hosts a request lands on, the per-host cap of 2 binds well before the global one does. Sized for the fleet, reuse comes back in line with a client per bot while retention stays collapsed into one pool.
You can share the connection with the same per-request auth guarantees as an unshared one. Media requests carry their own auth per request, and the client sends no cookies. A shared connection therefore doesn’t leak anything between sessions beyond what the shared source IP already reveals — with two exceptions. A live pooled connection reused across sessions lets the CDN see both sessions’ requests on the same TCP/TLS stream, a stronger correlation signal than a shared IP alone. TLS session resumption is the other: it lets a server correlate two sessions even across a source-IP change. Both are why sharing stays opt-in rather than the default.
Usage Examples
Basic GET Request
POST request with body
Streaming Download
Internal Implementation
The UreqHttpClient wraps a shared ureq::Agent for connection pooling. All requests go through the agent rather than standalone ureq::get()/ureq::post() functions:
When the danger-skip-tls-verify feature is enabled, the agent is built with TLS verification disabled:
UreqHttpClient::new() (the default agent) tracks whether it has ever dispatched a request it could actually send. Before that, resource_report() reports pool_connections: Some(0), pool_buffer_bytes: Some(0), inflight_bytes: None — ureq allocates its buffers per connection, not per agent, so a client that has never connected really does hold an empty pool. Once a request has gone out, it reports the cap instead: up to 3 idle connections, each with a 16 KiB input and 16 KiB output buffer, so pool_connections: Some(3), pool_buffer_bytes: Some(96 * 1024). That cap is an upper bound estimate, not a live measurement, and it stays set even if every subsequent request fails — a failed or redirected request still opens (or reuses) a connection, so the pool is no longer provably empty. “Dispatched” is decided before the request goes on the wire — a request ureq itself refuses to build (an unsupported method, a malformed URI, or a header it rejects) never flips the flag, and neither does a request that reaches the wire but carries Connection: close (see above): ureq closes that connection at cleanup instead of pooling it, so nothing is retained for the flag to describe. UreqHttpClient::with_agent(...) (a caller-supplied agent) reports None throughout — its buffer and pool configuration is opaque, and since every clone of that agent shares one pool it may already have connected before reaching this client, so an empty pool isn’t a safe guess either.
Cloning a client shares the underlying ureq::Agent and therefore its pool: a request made through one clone is reflected in every other clone’s resource_report(), including clones held by other sessions when you’ve shared one client across many sessions. pool_connections and pool_buffer_bytes describe that one shared pool, not a per-session allocation. If you sum Client::resource_report().await.http across sessions that share a client, sum it once for the shared agent instead of once per session — otherwise you count the same pool N times over.
ureq 3.4 treats any 4xx/5xx as ureq::Error::StatusCode by default — the opposite of the HttpClient contract above. UreqHttpClient sends every request through status_as_response, which disables http_status_as_error per request instead of once on the shared agent. That placement matters: a caller-supplied agent from with_agent carries ureq’s own defaults, not UreqHttpClient’s, so only a per-request override reaches it too:
The execute method clones the agent and wraps the call in spawn_blocking:
read_body is where the non-2xx contract meets your max_body_bytes cap, and it treats the two cases differently. A 2xx body is the payload, so an over-cap read stays an Err — you must never mistake a truncated media file for a complete one. A non-2xx body is diagnostic text (an error page), so read_body truncates it instead of erroring. That truncation cap is 64 KiB on top of max_body_bytes, not instead of it: losing the tail of an error page costs nothing, but losing the status code costs the media-conn refresh:
The execute_streaming method is synchronous (no spawn_blocking) because it’s called from within a blocking context. It also uses the shared agent and status_as_response:
Notice execute_streaming doesn’t error when a body exceeds max_body_bytes, unlike read_body above. std::io::Read::take reaches EOF at the cap instead of raising an error, so execute_streaming itself always returns Ok once the response headers arrive.
That silent truncation is safe inside Client::download and download_to_writer: they decrypt the result and verify a MAC or SHA over it, so a body truncated at the cap fails that check instead of passing as a complete file. It is not safe if you call execute_streaming directly, the way the Streaming Download example above does — that example has no integrity check of its own, so a truncated body would come back as an ordinary success. If you call execute_streaming directly, check the response against an expected length (a Content-Length header, for example) or your own integrity check before you trust it.
execute_upload follows the buffered shape, not the streaming one: it applies status_as_response to the request builder, then reads the response through read_body. This is what makes upload’s auth-error retry reachable at all. Before this fix, a rejected upload’s status was swallowed into an opaque transport error, so the media-conn refresh it should have triggered never ran.
Implementing custom HTTP clients
You can implement custom HTTP clients for different runtimes or requirements.
Example: Reqwest client (async)
Notice what this example doesn’t do: it never calls .error_for_status(). reqwest::RequestBuilder::send() (req.send() above) already returns Ok for a non-2xx status by default, so this example matches the HttpClient contract for free. Don’t add .error_for_status() here — it turns every 4xx/5xx into an Err and breaks the contract.
Example: mock client for testing
Usage with Bot builder
Best Practices
- Blocking operations - Always wrap blocking HTTP libraries in
tokio::task::spawn_blocking
- Streaming for large files - Use
execute_streaming for media downloads to avoid buffering
- Error handling - Return descriptive errors with context
- Timeouts - ureq 3.4 applies per-IP connection timeouts automatically; implement request-level timeouts for reliability
- Retries - Consider retry logic for transient failures
- Connection pooling - Use a shared
ureq::Agent (as UreqHttpClient does) for connection reuse within a session, and share one UreqHttpClient across sessions when running many of them in one process
The HTTP client is primarily used for media operations in whatsapp-rust:
The client manages media connections internally. Use the high-level upload method instead of building requests manually:
Under the hood, the client uses the HTTP client to:
- Fetch media connection credentials from WhatsApp servers
- Encrypt the media with AES-256-CBC
- Upload to the CDN with proper auth headers
- Parse the response for
direct_path and file hashes
Testing
Unit test example
See Also