Reqwest: Building an HTTP Request
A fluent builder turns an HTTP operation into a readable sequence of choices and boundaries.
Start with the consumer
We want to send an authenticated JSON request, reject unsuccessful HTTP statuses, and decode the response into our own Rust type. This is the part of the consumer program:
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct NewMessage<'a> {
title: &'a str,
body: &'a str,
}
#[derive(Debug, Deserialize)]
struct HttpBinResponse {
json: Message,
}
#[derive(Debug, Deserialize)]
struct Message {
title: String,
body: String,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
let message = NewMessage {
title: "Hello",
body: "Sent from reqwest",
};
let response = client
.post("https://httpbin.org/post")
.bearer_auth("example-token")
.json(&message)
.send()
.await?
.error_for_status()?
.json::<HttpBinResponse>()
.await?;
println!("title: {}", response.json.title);
println!("body: {}", response.json.body);
Ok(())
}
Running it produces:
title: Hello
body: Sent from reqwest
The rest of the chapter explains why the consumer code has this shape. You do not need to run it to follow the case study.
The central call chain reads in the order the consumer thinks:
choose method and URL
→ add authentication
→ encode the request body
→ send it
→ reject bad statuses
→ decode the response body
The API separates three phases that are easy to blur together:
- Configuration produces a
RequestBuilder. - Execution consumes it and asynchronously produces a
Response. - Interpretation checks status and consumes the body into a chosen type.
The data types belong to the consumer
#![allow(unused)]
fn main() {
#[derive(Serialize)]
struct NewMessage<'a> {
title: &'a str,
body: &'a str,
}
#[derive(Debug, Deserialize)]
struct HttpBinResponse {
json: Message,
}
}
Reqwest does not require request and response structs to inherit from one of
its own base types. It accepts any request value implementing Serde’s
Serialize trait and can produce any owned response type implementing
DeserializeOwned.
The request borrows its strings because serialization only needs to inspect
them during .json(&message). The decoded response owns its strings because it
must remain valid after the temporary response bytes are gone.
That asymmetry is useful API design: accept borrowed data where the operation is temporary; produce owned data when the result must stand alone.
Start with a reusable client
#![allow(unused)]
fn main() {
let client = reqwest::Client::new();
}
Client holds reusable connection state. Its methods take &self, so one
client can start many requests without being consumed:
#![allow(unused)]
fn main() {
pub fn post<U: IntoUrl>(&self, url: U) -> RequestBuilder {
self.request(Method::POST, url)
}
}
Two design choices are visible in this small signature:
&selfcommunicates reuse;U: IntoUrlaccepts several URL-like inputs while centralizing validation.
post is only vocabulary. It delegates to the general request operation
with Method::POST. The convenience method makes the common call site obvious
without creating a separate implementation path.
Client::new() is the low-ceremony default. Reqwest also exposes
Client::builder() when construction needs configuration or fallible error
handling. This is a common Rust pattern: a short default path paired with an
explicit builder for policy.
Source: how Client represents reuse
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct Client {
inner: Arc<ClientRef>,
}
pub fn post<U: IntoUrl>(&self, url: U) -> RequestBuilder {
self.request(Method::POST, url)
}
pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
let req = url
.into_url()
.map(move |url| Request::new(method, url));
RequestBuilder::new(self.clone(), req)
}
}
The public Client is a small cloneable handle around shared internal state.
Starting a request clones that handle into its builder; it does not duplicate
the connection pool.
A builder represents an unfinished request
After .post(...), the value is a RequestBuilder, not a Request and not a
Response. Each configuration method takes and returns self, which enables
the fluent chain:
#![allow(unused)]
fn main() {
pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> RequestBuilder
}
Read that signature aloud:
Consume this builder, borrow any serializable value, and return the updated builder.
Serialize decouples Reqwest from the consumer’s concrete data type. ?Sized
also permits dynamically sized serializable values. Borrowing &T means the
payload need not be transferred into Reqwest merely to encode it.
Inside, .json(...) does two related jobs: it serializes the body and supplies
the default Content-Type: application/json header. That is good convenience
because the two facts should normally agree. Reqwest uses or_insert_with, so
an explicitly chosen content type is not overwritten.
The builder carries deferred errors
URL parsing, header conversion, and JSON serialization can fail while the
chain is still being assembled. Returning Result<RequestBuilder, Error> from
every method would interrupt the fluent interface.
Instead, RequestBuilder internally carries a Result<Request, Error>.
Configuration methods preserve any error, and the error emerges at a natural
boundary:
#![allow(unused)]
fn main() {
pub fn build(self) -> crate::Result<Request> {
self.request
}
}
or:
#![allow(unused)]
fn main() {
pub fn send(self) -> impl Future<Output = Result<Response, crate::Error>>
}
This is a deliberate tradeoff. The happy path stays readable, but an error may be reported later than the call that caused it. The resulting error must retain enough context for diagnosis.
The separate .build() operation is especially valuable for testing and
middleware: consumers can inspect or modify a concrete request without sending
it.
Source: RequestBuilder and the concrete Request
#![allow(unused)]
fn main() {
pub struct Request {
method: Method,
url: Url,
headers: HeaderMap,
body: Option<Body>,
version: Version,
extensions: Extensions,
}
#[must_use = "RequestBuilder does nothing until you 'send' it"]
pub struct RequestBuilder {
client: Client,
request: crate::Result<Request>,
}
}
This definition explains several consumer-visible behaviors:
- the concrete
Requestcontains HTTP data and can be inspected independently; - the builder retains the
Clientthat will execute the request; - the builder retains either a partially configured request or an earlier construction error;
#[must_use]warns when a consumer configures a request and then forgets to send or build it.
Deeper: JSON configuration and deferred failure
#![allow(unused)]
fn main() {
pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> RequestBuilder {
let mut error = None;
if let Ok(ref mut req) = self.request {
match serde_json::to_vec(json) {
Ok(body) => {
req.headers_mut()
.entry(CONTENT_TYPE)
.or_insert_with(|| {
HeaderValue::from_static("application/json")
});
*req.body_mut() = Some(body.into());
}
Err(err) => error = Some(crate::error::builder(err)),
}
}
if let Some(err) = error {
self.request = Err(err);
}
self
}
}
The serialization error is stored back inside the builder. Later builder
methods remain chainable, but build or send eventually returns that error.
send is the execution boundary
#![allow(unused)]
fn main() {
pub fn send(self) -> impl Future<Output = Result<Response, crate::Error>>
}
Three parts of this signature define the experience:
selfconsumes the builder, so the same request is not accidentally sent again;impl Futureexposes asynchronous work without exposing the concrete future implementation;Result<Response, Error>makes transport failure explicit.
Calling .send() creates a future; .await allows the Tokio runtime to work
on other tasks while the network operation is pending. The first ? propagates
request construction or transport errors.
Source: from builder to client execution
#![allow(unused)]
fn main() {
pub fn send(self) -> impl Future<Output = Result<Response, crate::Error>> {
match self.request {
Ok(req) => self.client.execute_request(req),
Err(err) => Pending::new_err(err),
}
}
pub fn execute(
&self,
request: Request,
) -> impl Future<Output = Result<Response, crate::Error>> {
self.execute_request(request)
}
}
send is convenience over the more general Client::execute. The builder
already has both required pieces—the client and the request—so it can join them
at the execution boundary.
Deeper: the first checks inside execute_request
#![allow(unused)]
fn main() {
pub(super) fn execute_request(&self, req: Request) -> Pending {
let (method, url, mut headers, body, version, extensions) = req.pieces();
if url.scheme() != "http" && url.scheme() != "https" {
return Pending::new_err(error::url_bad_scheme(url));
}
if self.inner.https_only && url.scheme() != "https" {
return Pending::new_err(error::url_bad_scheme(url));
}
// Apply client defaults without replacing request-specific headers.
for (key, value) in &self.inner.headers {
if let Entry::Vacant(entry) = headers.entry(key) {
entry.insert(value.clone());
}
}
// Transport setup continues...
}
}
This is a useful stopping point. The code explains visible policy—valid schemes, HTTPS-only mode, and request headers overriding client defaults. Going deeper into pooling and Hyper would teach HTTP internals, not this public API boundary.
HTTP failure is a policy choice
A server response with status 404 or 500 is still a successfully received
HTTP response. Therefore .send().await? does not reject it. The consumer opts
into that policy with:
#![allow(unused)]
fn main() {
pub fn error_for_status(self) -> crate::Result<Self>
}
For a client or server error status, this consumes the response and returns an error containing the status and URL. Otherwise, it returns the same response so chaining can continue.
This separation is important. Reqwest does not pretend that transport success and application success are the same concept, nor does it impose one policy on every consumer.
Source: the response type and status policy
#![allow(unused)]
fn main() {
pub struct Response {
pub(super) res: hyper::Response<ResponseBody>,
url: Box<Url>,
}
pub fn error_for_status(self) -> crate::Result<Self> {
let status = self.status();
let reason = self
.extensions()
.get::<hyper::ext::ReasonPhrase>()
.cloned();
if status.is_client_error() || status.is_server_error() {
Err(crate::error::status_code(*self.url, status, reason))
} else {
Ok(self)
}
}
}
Reqwest retains the final URL beside Hyper’s response partly so errors can
carry useful request context. Consuming self lets the success branch return
the same response and the error branch move its URL into the error.
The output type drives decoding
#![allow(unused)]
fn main() {
pub async fn json<T: DeserializeOwned>(self) -> crate::Result<T>
}
The caller selects T, here with .json::<HttpBinResponse>(). The method
consumes the response because reading a network body is a one-way operation.
It first collects the body bytes, then asks Serde to construct T.
DeserializeOwned is stronger than Deserialize<'a>: it says the returned
value cannot borrow from the temporary body buffer. That matches what Reqwest
can safely promise after the method returns.
The two .json methods deliberately mirror each other while requiring
different traits:
| Direction | Method receiver | Data bound | Ownership idea |
|---|---|---|---|
| Rust → request body | RequestBuilder | T: Serialize + ?Sized | borrow input briefly |
| response body → Rust | Response | T: DeserializeOwned | return independent data |
Source: collect bytes, then deserialize the caller’s type
#![allow(unused)]
fn main() {
pub async fn json<T: DeserializeOwned>(self) -> crate::Result<T> {
let (full, url) = self.do_bytes().await?;
serde_json::from_slice(&full)
.map_err(|err| crate::error::decode(err).with_url(*url))
}
}
The implementation is small because Serde owns the generic decoding mechanism. Reqwest contributes transport, buffering, and URL-aware error context.
Follow the call in Neovim
Start on the consumer expression and use gd in this order:
Client::post
→ Client::request
→ RequestBuilder::json
→ RequestBuilder::send
→ Client::execute_request
→ Response::error_for_status
→ Response::json
You do not need to understand Reqwest’s entire networking stack. Stop at each public boundary and ask what the signature promises to the caller. Enter the private implementation only to explain a visible behavior.
Relevant files in the local checkout:
src/async_impl/client.rs— client construction and HTTP verb methods;src/async_impl/request.rs— request configuration, building, and sending;src/async_impl/response.rs— status policy and body decoding;src/error.rs— Reqwest’s unified public error type.
Why this API works
- The chain follows the consumer’s mental sequence.
- Distinct types represent the unfinished request and received response.
- Ownership marks one-way boundaries: send once, consume the body once.
- Trait bounds integrate consumer-owned data without framework base classes.
- Convenience methods encode related defaults while preserving escape hatches.
- Async mechanics are visible exactly where waiting occurs.
- HTTP status policy remains explicit rather than being silently imposed.
Costs and questions
The design is not free of tradeoffs:
- Deferred builder errors improve chaining but separate cause from reporting.
- A single broad
reqwest::Erroris convenient, but consumers classify it through methods such asis_timeout,is_status, andstatus. - Collecting
.json()into an owned value is convenient but buffers the body; streaming requires another API. - A generic fluent chain can make intermediate types less obvious to beginners.
Client::new()may panic if its environment cannot initialize; the builder path returns that failure instead.
These are useful interview questions because they ask where convenience should end and explicit control should begin.
Stress the design
These requirement changes reveal the public boundaries:
- Replace
.send()with.build()and inspect the method, URL, headers, and body without touching the network. - Remove
.error_for_status()and observe that a404remains aResponse. - Remove the type annotation from
.json::<HttpBinResponse>()and see whether later usage provides enough information for inference. - Compare the async and blocking chains. Which API concepts remain identical, and which mechanics disappear?
- Reuse one
Clientfor several requests, then compare it with the top-levelreqwest::getconvenience function, which creates a client for the call.
Design takeaway
A strong staged API gives each phase its own type, uses ownership to mark irreversible transitions, and lets the consumer opt into policy at explicit boundaries.
Source trail
This case study follows Reqwest commit 9f06fd2 from the local checkout: