Guide contents

Lesson 5 of 6

HTTP Clients with reqwest

reqwest is the standard HTTP client for Rust. It's async and runs on Tokio, it handles connection pooling, redirects, TLS, compression, and proxies, and with serde it turns JSON responses straight into your structs.

Terminal
cargo add reqwest --features json,query,form
cargo add tokio --features full
cargo add serde --features derive

Since version 0.13, json, query, and form are opt-in features. HTTPS works out of the box.

Task reqwest
A configured, reusable client Client::builder().timeout(...).build()?
A GET request client.get(url)
Bearer token authentication .bearer_auth(t)
A JSON request body .json(&value)
The status code response.status()
A JSON response body response.json::<T>().await?
Connection errors and timeouts reqwest::Error with is_connect(), is_timeout(), status()
A fake server for tests a small axum server on port 0

To keep every example runnable without internet access, each one starts a tiny local server with axum from the previous lesson, on a port the operating system chooses, and then calls it. Against a real API, only the URL changes.

Part 1: Making requests

1. The simplest GET

reqwest::get sends one GET request with a default client. send completes when the response headers arrive, and .text() reads the body. Both steps are awaited, and both can fail.

Rust
use axum::{Router, routing::get};

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let base = start_server(Router::new().route("/hello", get(|| async { "Hello, client!" }))).await;

    let response = reqwest::get(format!("{base}/hello")).await?;
    println!("status: {}", response.status());
    println!("content-type: {:?}", response.headers()["content-type"]);
    println!("body: {}", response.text().await?);
    Ok(())
}
Output
status: 200 OK
content-type: "text/plain; charset=utf-8"
body: Hello, client!

Against a real API, it's the same line: reqwest::get("https://api.github.com/zen").await?.text().await?.

2. A configured, reusable Client

reqwest::get creates a new client each time. Real code builds one Client at startup and reuses it: it keeps a pool of open connections, so later requests to the same host skip the connection and TLS handshake. Client is cheap to clone (it's an Arc inside), so share it freely between tasks.

Rust
use axum::http::HeaderMap;
use axum::{Router, routing::get};
use reqwest::Client;
use reqwest::header::{self, HeaderValue};
use std::time::Duration;

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

// The fake API echoes two request headers back.
async fn echo_headers(headers: HeaderMap) -> String {
    let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or("-").to_string();
    format!("user-agent={} x-client-version={}", get("user-agent"), get("x-client-version"))
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let base = start_server(Router::new().route("/echo", get(echo_headers))).await;

    let mut defaults = header::HeaderMap::new();
    defaults.insert("x-client-version", HeaderValue::from_static("2.3.0"));
    let client = Client::builder()
        .user_agent("inventory-service/2.3")
        .default_headers(defaults)
        .timeout(Duration::from_secs(10)) // the whole request, including reading the body
        .connect_timeout(Duration::from_secs(2))
        .build()?;

    for _ in 0..2 {
        println!("{}", client.get(format!("{base}/echo")).send().await?.text().await?);
    }
    Ok(())
}
Output
user-agent=inventory-service/2.3 x-client-version=2.3.0
user-agent=inventory-service/2.3 x-client-version=2.3.0

3. JSON responses into structs

.json::<T>() deserializes the body with serde. The struct only needs the fields you use; the rest of the response is ignored (Serialization with serde).

Rust
use axum::{Json, Router, routing::get};
use serde::Deserialize;
use serde_json::json;

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

#[derive(Debug, Deserialize)]
struct Repo {
    name: String,
    stars: u32,
    topics: Vec<String>,
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let api = Router::new().route(
        "/repos",
        get(|| async {
            Json(json!([
                {"name": "tokio", "stars": 30000, "topics": ["async"], "license": "MIT"},
                {"name": "serde", "stars": 9000, "topics": ["serialization", "json"], "license": "MIT"}
            ]))
        }),
    );
    let base = start_server(api).await;

    let repos: Vec<Repo> = reqwest::get(format!("{base}/repos")).await?.json().await?;
    for repo in &repos {
        println!("{:<6} {:>6} stars {:?}", repo.name, repo.stars, repo.topics);
    }
    Ok(())
}
Output
tokio   30000 stars ["async"]
serde    9000 stars ["serialization", "json"]

4. Sending JSON, query strings, forms, and headers

The request builder adds everything a request can carry. .json(&value) serializes a body and sets content-type: application/json; .query(&...) appends URL-encoded query parameters; .form(&...) sends an HTML form; .bearer_auth and .header add headers.

Rust
use axum::extract::{Form, Query};
use axum::http::HeaderMap;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

#[derive(Serialize, Deserialize, Debug)]
struct NewOrder {
    sku: String,
    qty: u32,
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    // A fake API that describes what it received.
    let api = Router::new()
        .route(
            "/orders",
            post(|headers: HeaderMap, Json(order): Json<NewOrder>| async move {
                format!("order {order:?}, auth: {:?}", headers.get("authorization").map(|v| v.to_str().unwrap().to_string()))
            }),
        )
        .route("/search", get(|Query(q): Query<BTreeMap<String, String>>| async move { format!("query {q:?}") }))
        .route("/login", post(|Form(f): Form<BTreeMap<String, String>>| async move { format!("form {f:?}") }));
    let base = start_server(api).await;
    let client = reqwest::Client::new();

    let order = NewOrder { sku: String::from("LAMP-7"), qty: 2 };
    let reply = client.post(format!("{base}/orders")).bearer_auth("token-123").json(&order).send().await?.text().await?;
    println!("{reply}");

    let reply = client.get(format!("{base}/search")).query(&[("q", "rust & tokio"), ("page", "2")]).send().await?.text().await?;
    println!("{reply}");

    let reply = client.post(format!("{base}/login")).form(&[("user", "ana"), ("remember", "true")]).send().await?.text().await?;
    println!("{reply}");
    Ok(())
}
Output
order NewOrder { sku: "LAMP-7", qty: 2 }, auth: Some("Bearer token-123")
query {"page": "2", "q": "rust & tokio"}
form {"remember": "true", "user": "ana"}

.query takes care of escaping: rust & tokio arrives intact instead of breaking the query string.

Part 2: When things go wrong

5. Status codes are not errors

An HTTP 404 or 500 is still a successful request: send() returns Ok, and you check the status. error_for_status() turns 4xx and 5xx responses into an Err, which is what you usually want before parsing the body. Real failures, such as a refused connection, are Err from send() itself, and the error has methods to tell them apart.

Rust
use axum::http::StatusCode;
use axum::{Router, routing::get};

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

async fn closed_port_url() -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    drop(listener); // nothing listens on this port anymore
    format!("http://{addr}")
}

#[tokio::main]
async fn main() {
    let api = Router::new().route("/ok", get(|| async { "fine" })).route("/broken", get(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "db down") }));
    let base = start_server(api).await;

    let response = reqwest::get(format!("{base}/missing")).await.unwrap();
    println!("GET /missing: send succeeded, status {}", response.status());

    for path in ["/ok", "/broken"] {
        let result = reqwest::get(format!("{base}{path}")).await.and_then(|r| r.error_for_status());
        match result {
            Ok(r) => println!("GET {path}: ok, {}", r.status()),
            Err(e) => println!("GET {path}: error with status {:?}", e.status()),
        }
    }

    match reqwest::get(closed_port_url().await).await {
        Ok(r) => println!("unexpected: {}", r.status()),
        Err(e) => println!("closed port: is_connect={}, message: {}", e.is_connect(), e.without_url()),
    }
}
Output
GET /missing: send succeeded, status 404 Not Found
GET /ok: ok, 200 OK
GET /broken: error with status Some(500)
closed port: is_connect=true, message: error sending request

without_url() removes the URL from the message; it's used here only because the port number differs on every run.

6. Timeouts

Without a timeout, a request to a server that never answers can wait for a very long time. Set a default on the Client and override it per request with .timeout(...) where an operation is known to be slow or must be fast.

Rust
use axum::{Router, routing::get};
use std::time::Duration;

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let slow = get(|| async {
        tokio::time::sleep(Duration::from_millis(300)).await;
        "finally"
    });
    let base = start_server(Router::new().route("/slow", slow)).await;
    let client = reqwest::Client::builder().timeout(Duration::from_secs(5)).build()?;

    match client.get(format!("{base}/slow")).timeout(Duration::from_millis(50)).send().await {
        Ok(r) => println!("got {}", r.status()),
        Err(e) => println!("50 ms timeout: is_timeout={}", e.is_timeout()),
    }

    let body = client.get(format!("{base}/slow")).send().await?.text().await?; // the 5 s default applies
    println!("with the default timeout: {body}");
    Ok(())
}
Output
50 ms timeout: is_timeout=true
with the default timeout: finally

7. Retrying with backoff

Transient failures, such as a 503 during a deploy or a timeout, are worth retrying; client errors like 400 or 404 are not. Wait a little longer before each attempt (exponential backoff) so a struggling server isn't flooded. In production, add random jitter to the delays with rand (Dates, Regex & Everyday Crates).

Rust
use axum::http::StatusCode;
use axum::{Router, routing::get};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

fn is_retryable(result: &Result<reqwest::Response, reqwest::Error>) -> bool {
    match result {
        Ok(r) => r.status().is_server_error() || r.status() == StatusCode::TOO_MANY_REQUESTS,
        Err(e) => e.is_timeout() || e.is_connect(),
    }
}

async fn get_with_retry(client: &reqwest::Client, url: &str, max_attempts: u32) -> Result<reqwest::Response, reqwest::Error> {
    let mut delay = Duration::from_millis(20);
    let mut attempt = 1;
    loop {
        let result = client.get(url).send().await;
        if attempt == max_attempts || !is_retryable(&result) {
            return result;
        }
        println!("  attempt {attempt} failed ({}), retrying in {delay:?}", result.as_ref().map_or(String::from("error"), |r| r.status().to_string()));
        tokio::time::sleep(delay).await;
        delay *= 2;
        attempt += 1;
    }
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    // Fails twice with 503, then succeeds.
    let calls = Arc::new(AtomicU32::new(0));
    let flaky = get(move || async move {
        if calls.fetch_add(1, Ordering::SeqCst) < 2 { (StatusCode::SERVICE_UNAVAILABLE, "busy") } else { (StatusCode::OK, "done") }
    });
    let api = Router::new().route("/flaky", flaky).route("/gone", get(|| async { StatusCode::NOT_FOUND }));
    let base = start_server(api).await;
    let client = reqwest::Client::new();

    println!("GET /flaky");
    let r = get_with_retry(&client, &format!("{base}/flaky"), 5).await?;
    println!("  -> {} {:?}", r.status(), r.text().await?);

    println!("GET /gone");
    let r = get_with_retry(&client, &format!("{base}/gone"), 5).await?;
    println!("  -> {} (not retried)", r.status());
    Ok(())
}
Output
GET /flaky
  attempt 1 failed (503 Service Unavailable), retrying in 20ms
  attempt 2 failed (503 Service Unavailable), retrying in 40ms
  -> 200 OK "done"
GET /gone
  -> 404 Not Found (not retried)

Only retry requests that are safe to repeat: a GET, or a POST the server deduplicates, for example with an idempotency key header.

Part 3: More than one request

8. Many requests at once, with a limit

Requests are futures, so many can be in flight at once (Tokio Patterns). Without a limit, a thousand URLs means a thousand simultaneous connections; a Semaphore caps how many run at the same time, here three.

Rust
use axum::extract::{Path, State};
use axum::{Router, routing::get};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

#[derive(Default)]
struct Load {
    in_flight: AtomicUsize,
    max_seen: AtomicUsize,
}

async fn price(State(load): State<Arc<Load>>, Path(id): Path<u32>) -> String {
    let now = load.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
    load.max_seen.fetch_max(now, Ordering::SeqCst);
    tokio::time::sleep(Duration::from_millis(30)).await;
    load.in_flight.fetch_sub(1, Ordering::SeqCst);
    format!("{}", id * 100)
}

#[tokio::main]
async fn main() {
    let load = Arc::new(Load::default());
    let base = start_server(Router::new().route("/price/{id}", get(price)).with_state(load.clone())).await;

    let client = reqwest::Client::new();
    let limit = Arc::new(Semaphore::new(3));
    let mut tasks = JoinSet::new();
    for id in 1..=10u32 {
        let (client, limit, url) = (client.clone(), limit.clone(), format!("{base}/price/{id}"));
        tasks.spawn(async move {
            let _permit = limit.acquire().await.unwrap(); // waits while 3 requests are running
            let body = client.get(url).send().await?.text().await?;
            Ok::<_, reqwest::Error>((id, body))
        });
    }

    let mut prices = Vec::new();
    while let Some(result) = tasks.join_next().await {
        prices.push(result.unwrap().unwrap());
    }
    prices.sort();
    println!("{prices:?}");
    println!("never more than 3 in flight: {}", load.max_seen.load(Ordering::SeqCst) <= 3);
}
Output
[(1, "100"), (2, "200"), (3, "300"), (4, "400"), (5, "500"), (6, "600"), (7, "700"), (8, "800"), (9, "900"), (10, "1000")]
never more than 3 in flight: true

9. Streaming a large download

.text() and .json() hold the whole body in memory. For large files, read the body chunk by chunk with .chunk() and write each piece to disk as it arrives, so memory use stays small.

Rust
use axum::{Router, routing::get};
use std::fs::File;
use std::io::{BufWriter, Write};

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let big_file = get(|| async { vec![b'x'; 5 * 1024 * 1024] }); // 5 MB response
    let base = start_server(Router::new().route("/export.csv", big_file)).await;

    let mut response = reqwest::get(format!("{base}/export.csv")).await?.error_for_status()?;
    println!("announced size: {:?} bytes", response.content_length());

    let mut out = BufWriter::new(File::create("export.csv")?);
    let mut written = 0;
    while let Some(chunk) = response.chunk().await? {
        out.write_all(&chunk)?;
        written += chunk.len();
    }
    out.flush()?;
    println!("wrote {written} bytes, file size {}", std::fs::metadata("export.csv")?.len());
    Ok(())
}
Output
announced size: Some(5242880) bytes
wrote 5242880 bytes, file size 5242880

Writing to a std::fs::File blocks the thread briefly for each chunk, which is fine for a one-off download. A server handling many downloads at once would use tokio::fs::File and AsyncWriteExt instead.

Part 4: A real API client

10. Realistic example: a typed client for an API

Wrapping an API in a small client type keeps URLs, authentication, and error handling in one place. The methods return domain types and a client-specific error enum, so callers never see raw HTTP details unless they want to.

Rust
use axum::extract::Path;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;

async fn start_server(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

// ---- the client library ----

#[derive(Debug, Deserialize)]
pub struct Customer {
    pub id: u32,
    pub name: String,
    pub credit_cents: i64,
}

#[derive(Serialize)]
struct CreditChange {
    delta_cents: i64,
    reason: String,
}

#[derive(Debug, thiserror::Error)]
pub enum CrmError {
    #[error("customer {0} not found")]
    NotFound(u32),
    #[error("invalid API token")]
    Unauthorized,
    #[error("CRM returned {status}: {message}")]
    Api { status: u16, message: String },
    #[error("could not reach the CRM")]
    Transport(#[from] reqwest::Error),
}

pub struct CrmClient {
    http: reqwest::Client,
    base_url: String,
    token: String,
}

impl CrmClient {
    pub fn new(base_url: &str, token: &str) -> Result<CrmClient, CrmError> {
        let http = reqwest::Client::builder().timeout(Duration::from_secs(5)).user_agent("billing/1.0").build()?;
        Ok(CrmClient { http, base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string() })
    }

    pub async fn customer(&self, id: u32) -> Result<Customer, CrmError> {
        let response = self.http.get(format!("{}/customers/{id}", self.base_url)).bearer_auth(&self.token).send().await?;
        Self::check(response, id).await?.json().await.map_err(CrmError::from)
    }

    pub async fn add_credit(&self, id: u32, delta_cents: i64, reason: &str) -> Result<Customer, CrmError> {
        let body = CreditChange { delta_cents, reason: reason.to_string() };
        let response = self.http.post(format!("{}/customers/{id}/credit", self.base_url)).bearer_auth(&self.token).json(&body).send().await?;
        Self::check(response, id).await?.json().await.map_err(CrmError::from)
    }

    async fn check(response: reqwest::Response, id: u32) -> Result<reqwest::Response, CrmError> {
        match response.status() {
            s if s.is_success() => Ok(response),
            StatusCode::NOT_FOUND => Err(CrmError::NotFound(id)),
            StatusCode::UNAUTHORIZED => Err(CrmError::Unauthorized),
            s => Err(CrmError::Api { status: s.as_u16(), message: response.text().await.unwrap_or_default() }),
        }
    }
}

// ---- a fake CRM for this page ----

fn authorized(headers: &HeaderMap) -> bool {
    headers.get("authorization").is_some_and(|v| v == "Bearer secret")
}

fn fake_crm() -> Router {
    Router::new()
        .route(
            "/customers/{id}",
            get(|headers: HeaderMap, Path(id): Path<u32>| async move {
                match (authorized(&headers), id) {
                    (false, _) => (StatusCode::UNAUTHORIZED, Json(json!({}))),
                    (true, 1) => (StatusCode::OK, Json(json!({"id": 1, "name": "Acme Corp", "credit_cents": 5000, "tier": "gold"}))),
                    (true, _) => (StatusCode::NOT_FOUND, Json(json!({"error": "no such customer"}))),
                }
            }),
        )
        .route(
            "/customers/{id}/credit",
            post(|Json(change): Json<serde_json::Value>| async move {
                let delta = change["delta_cents"].as_i64().unwrap_or(0);
                if delta.abs() > 100_000 {
                    return (StatusCode::UNPROCESSABLE_ENTITY, Json(json!("credit change over limit")));
                }
                (StatusCode::OK, Json(json!({"id": 1, "name": "Acme Corp", "credit_cents": 5000 + delta})))
            }),
        )
}

#[tokio::main]
async fn main() -> Result<(), CrmError> {
    let base = start_server(fake_crm()).await;
    let crm = CrmClient::new(&base, "secret")?;

    let customer = crm.customer(1).await?;
    println!("{customer:?}");
    println!("{:?}", crm.add_credit(1, 2_500, "goodwill").await.map(|c| c.credit_cents));

    for result in [crm.customer(99).await.map(|c| c.name), crm.add_credit(1, 500_000, "typo").await.map(|c| c.name)] {
        match result {
            Ok(name) => println!("ok: {name}"),
            Err(e) => println!("error: {e}"),
        }
    }
    let wrong_token = CrmClient::new(&base, "expired")?;
    println!("{}", wrong_token.customer(1).await.unwrap_err());
    Ok(())
}
Output
Customer { id: 1, name: "Acme Corp", credit_cents: 5000 }
Ok(7500)
error: customer 99 not found
error: CRM returned 422: "credit change over limit"
invalid API token

11. Testing code that calls an API

Code that talks to an external service is easiest to test when the base URL is a parameter, as in CrmClient::new. Tests then point it at a local fake server and check how your code handles each kind of answer. This test checks that a client function follows pagination until the last page.

Rustcargo test
use serde::Deserialize;

#[derive(Deserialize)]
struct Page {
    items: Vec<String>,
    next: Option<u32>,
}

/// Fetches every item by following the `next` page numbers.
pub async fn fetch_all(client: &reqwest::Client, base_url: &str) -> Result<Vec<String>, reqwest::Error> {
    let mut items = Vec::new();
    let mut page = Some(1);
    while let Some(n) = page {
        let response: Page = client.get(format!("{base_url}/items")).query(&[("page", n)]).send().await?.error_for_status()?.json().await?;
        items.extend(response.items);
        page = response.next;
    }
    Ok(items)
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::extract::Query;
    use axum::{Json, Router, routing::get};
    use serde_json::{Value, json};
    use std::collections::HashMap;

    async fn fake_api() -> String {
        let pages = get(|Query(q): Query<HashMap<String, u32>>| async move {
            let body: Value = match q.get("page") {
                Some(1) => json!({"items": ["a", "b"], "next": 2}),
                Some(2) => json!({"items": ["c"], "next": 3}),
                _ => json!({"items": ["d"], "next": null}),
            };
            Json(body)
        });
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        tokio::spawn(async move { axum::serve(listener, Router::new().route("/items", pages)).await.unwrap() });
        format!("http://{addr}")
    }

    #[tokio::test]
    async fn follows_every_page() {
        let base = fake_api().await;
        let items = fetch_all(&reqwest::Client::new(), &base).await.unwrap();
        assert_eq!(items, ["a", "b", "c", "d"]);
    }

    #[tokio::test]
    async fn reports_server_errors() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        let failing = Router::new().route("/items", get(|| async { axum::http::StatusCode::BAD_GATEWAY }));
        tokio::spawn(async move { axum::serve(listener, failing).await.unwrap() });

        let err = fetch_all(&reqwest::Client::new(), &format!("http://{addr}")).await.unwrap_err();
        assert_eq!(err.status(), Some(reqwest::StatusCode::BAD_GATEWAY));
    }
}
Output
running 2 tests
test tests::follows_every_page ... ok
test tests::reports_server_errors ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

12. Blocking requests

Command-line tools and scripts that don't use async at all can enable reqwest's blocking feature, which provides the same API without async and .await (it runs a small Tokio runtime internally). Don't use it inside async code: it would block the runtime's threads.

Rust
// Cargo.toml: reqwest = { version = "0.13", features = ["blocking", "json"] }
fn main() -> Result<(), reqwest::Error> {
    let client = reqwest::blocking::Client::new();
    let body = client.get("https://api.github.com/zen").header("user-agent", "demo").send()?.text()?;
    println!("{body}");
    Ok(())
}

Summary

  • Build one reqwest::Client with timeouts and default headers, and clone it wherever it's needed; it pools connections.
  • The request builder adds .json, .query, .form, .bearer_auth, and .header; .send().await? returns the response once its headers arrive.
  • .json::<T>(), .text(), and .chunk() read the body into a struct, a string, or piece by piece.
  • 4xx and 5xx responses aren't errors until you call error_for_status(); transport failures are, with is_connect(), is_timeout(), and status() to tell them apart.
  • Retry only transient failures, with growing delays; limit concurrency with a Semaphore.
  • Wrap each external API in a small typed client with its own error enum, and test it against a local fake server.

Next: Databases with sqlx.