The HTTP client you actually need
You are building a CLI tool that checks a status endpoint. Or a bot that polls an API every few seconds. Or a service that needs to fetch a JSON config file from a remote server. Rust's standard library gives you TCP sockets, but building an HTTP client from scratch means handling headers, chunked encoding, TLS handshakes, and redirects yourself. That is reinventing the wheel.
You reach for reqwest. It is the de facto HTTP client for Rust. It supports async, TLS, JSON, streaming, and connection pooling. It feels familiar if you have used requests in Python or fetch in JavaScript. But there are three traps waiting for newcomers: the async runtime requirement, the TLS feature flags, and the fact that the convenience function reqwest::get hides the real power of the crate.
Setup and the TLS decision
Add reqwest to your Cargo.toml. You also need an async runtime. reqwest is async by default, which means it relies on a runtime to drive the network operations. tokio is the standard choice.
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
Notice default-features = false and features = ["rustls-tls"]. This is a convention. reqwest enables native-tls by default, which pulls in system libraries like OpenSSL on Linux or Schannel on Windows. rustls is a pure Rust TLS implementation. It compiles everywhere without installing system dependencies. The community prefers rustls for reproducibility and cross-compilation. If you omit this configuration, your build might fail on a minimal Docker image because the system TLS libraries are missing.
Convention aside: stick with rustls unless you have a specific reason to touch the system certificate store. It saves headaches on embedded targets and CI runners.
Minimal example
Here is the simplest way to fetch a URL. This uses the convenience function reqwest::get.
use reqwest;
/// Fetches the homepage and prints the status and body.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// reqwest::get returns a Future. .await drives it to completion.
let response = reqwest::get("https://www.rust-lang.org").await?;
// .text() reads the body as UTF-8 and consumes the response.
let text = response.text().await?;
println!("Status: {}", response.status());
println!("Body length: {} chars", text.len());
Ok(())
}
The #[tokio::main] attribute transforms your main function. It sets up the runtime and allows you to use .await. Without it, the compiler rejects the code. You will see an error about the Future trait not being implemented for the return type, or a panic if you try to block. The ? operator propagates errors. reqwest errors implement std::error::Error, so Box<dyn std::error::Error> catches them cleanly.
Async functions do not run themselves. They return a lazy computation. The runtime polls that computation until the network data arrives. Keep that mental model clear. You write the logic; the runtime executes it.
Realistic usage with Client
reqwest::get is fine for scripts. Real applications use reqwest::Client. The client manages a connection pool. It reuses TCP connections and TLS handshakes across requests. Creating a new client for every request kills performance.
Convention: create one Client at the top of your application and clone it wherever you need to make requests. reqwest::Client is cheap to clone. The clone shares the internal pool. It does not duplicate the state.
use reqwest::header;
use serde::Deserialize;
/// Represents the JSON response from the API.
#[derive(Deserialize, Debug)]
struct User {
id: u64,
name: String,
}
/// Demonstrates Client reuse, headers, and JSON parsing.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build a client with a timeout. reqwest has no default timeout.
// Without this, a hanging server will block the request forever.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
// Clone the client for the request. The pool is shared.
let user = fetch_user(&client).await?;
println!("User: {:?}", user);
Ok(())
}
/// Fetches a user by ID using the shared client.
async fn fetch_user(client: &reqwest::Client) -> Result<User, Box<dyn std::error::Error>> {
// Add a custom header. .header() returns the builder, allowing chaining.
let response = client
.get("https://jsonplaceholder.typicode.com/users/1")
.header(header::ACCEPT, "application/json")
.send()
.await?;
// .json() deserializes the body using serde.
// This requires the "json" or "serde" feature in Cargo.toml.
let user: User = response.json().await?;
Ok(user)
}
Add serde = { version = "1", features = ["derive"] } to your Cargo.toml if you want to use .json(). If you forget the json feature in reqwest, the compiler rejects the call with E0277 (trait bound not satisfied), complaining that the response type does not implement the deserialization trait.
The .json() method consumes the response body. You cannot call .json() and then .text() on the same response. The body is a stream. Once read, it is gone. If you need the body multiple times, buffer it into a Vec<u8> first.
Clone the client, not the request. The client holds the connection pool. Reusing it is the difference between a snappy app and one that stalls on every call.
Response body consumption
The response body is a stream. You can read it once. If you call .text(), the body is consumed. Calling .text() again panics or returns an error. This prevents accidental double-processing.
If you need to inspect the body in multiple ways, buffer it.
/// Buffers the body to allow multiple reads.
async fn inspect_body(response: reqwest::Response) -> Result<(), Box<dyn std::error::Error>> {
// .bytes() reads the entire body into a Vec<u8>.
let bytes = response.bytes().await?;
// Now you can slice, parse, or log the bytes safely.
println!("First 10 bytes: {:?}", &bytes[..10.min(bytes.len())]);
// You can also parse JSON from the bytes.
let text = String::from_utf8(bytes.to_vec())?;
println!("Length: {}", text.len());
Ok(())
}
If you try to use the response after calling a consuming method, the compiler catches you. You will get E0382 (use of moved value) because the consuming method takes ownership of the response or its body stream. This is a feature. It forces you to be explicit about data flow.
Trust the borrow checker here. It prevents you from accidentally draining a stream twice.
Error handling and inspection
reqwest errors are informative. They implement methods to help you decide how to retry.
/// Handles errors with specific retry logic.
async fn fetch_with_retry(url: &str) -> Result<reqwest::Response, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let mut attempts = 0;
loop {
match client.get(url).send().await {
Ok(response) => return Ok(response),
Err(e) => {
attempts += 1;
if attempts > 3 {
return Err(Box::new(e));
}
// Check if the error is a timeout or connection issue.
// These are often transient.
if e.is_timeout() || e.is_connect() {
println!("Transient error, retrying...");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
}
// Other errors like 404 or 500 are not transient.
return Err(Box::new(e));
}
}
}
}
Use is_timeout(), is_connect(), and is_status(). These methods let you write robust retry logic without parsing error strings. If your code hangs, check if you are blocking the async runtime with a CPU-heavy loop or a blocking call. reqwest is async. Mixing blocking code into an async runtime can starve the executor.
Async is a contract. You write the logic, the runtime executes it. Keep them separate.
Pitfalls and compiler errors
Newcomers hit a few common walls.
If you try to deserialize JSON without the serde feature, the compiler rejects the code with E0277. The error message points to the trait bound. Add features = ["json"] to reqwest in Cargo.toml.
If you forget #[tokio::main] and try to .await, the compiler complains that the future cannot be awaited in a synchronous context. You might see a panic at runtime if you use tokio::runtime::Runtime::new().block_on() incorrectly. Stick to the attribute macro for main.
If you create a Client inside a loop, you destroy performance. Each client creates a new pool. The pool never warms up. Move the client creation outside the loop.
If you do not set a timeout, a malicious or slow server can hold your connection open indefinitely. reqwest has no default timeout. Always set one in production code.
If your code hangs, check if you are blocking the async runtime with a CPU-heavy loop or a blocking call.
Decision matrix
Use reqwest::get for one-off scripts where you do not care about connection pooling. Use reqwest::Client for any application that makes multiple requests; the client reuses TCP connections and TLS handshakes. Use rustls-tls when you want a dependency-free build that works on Linux, macOS, Windows, and embedded targets without system libraries. Use native-tls only when you must rely on the host system's certificate store and trust chain. Use reqwest::blocking only when you are integrating with a legacy codebase that forbids async; otherwise, stick to the async API.