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.
cargo add reqwest --features json,query,form
cargo add tokio --features full
cargo add serde --features deriveSince 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.
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(())
}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.
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(())
}user-agent=inventory-service/2.3 x-client-version=2.3.0
user-agent=inventory-service/2.3 x-client-version=2.3.03. 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).
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(())
}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.
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(())
}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.
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()),
}
}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 requestwithout_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.
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(())
}50 ms timeout: is_timeout=true
with the default timeout: finally7. 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).
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(())
}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.
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);
}[(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: true9. 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.
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(())
}announced size: Some(5242880) bytes
wrote 5242880 bytes, file size 5242880Writing 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.
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(())
}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 token11. 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.
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));
}
}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.00s12. 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.
// 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::Clientwith 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, withis_connect(),is_timeout(), andstatus()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.
