Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Reqwest: Building an HTTP Request

A fluent builder turns an HTTP operation into a readable sequence of choices and boundaries.

Reqwest 0.13.4Client · builder · generics · async · SerdeRepository deep dive

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:

  1. Configuration produces a RequestBuilder.
  2. Execution consumes it and asynchronously produces a Response.
  3. 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:

  • &self communicates reuse;
  • U: IntoUrl accepts 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 Request contains HTTP data and can be inspected independently;
  • the builder retains the Client that 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:

  • self consumes the builder, so the same request is not accidentally sent again;
  • impl Future exposes 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:

DirectionMethod receiverData boundOwnership idea
Rust → request bodyRequestBuilderT: Serialize + ?Sizedborrow input briefly
response body → RustResponseT: DeserializeOwnedreturn 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::Error is convenient, but consumers classify it through methods such as is_timeout, is_status, and status.
  • 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:

  1. Replace .send() with .build() and inspect the method, URL, headers, and body without touching the network.
  2. Remove .error_for_status() and observe that a 404 remains a Response.
  3. Remove the type annotation from .json::<HttpBinResponse>() and see whether later usage provides enough information for inference.
  4. Compare the async and blocking chains. Which API concepts remain identical, and which mechanics disappear?
  5. Reuse one Client for several requests, then compare it with the top-level reqwest::get convenience 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: