The HTTP problem in Rust
You're writing a CLI tool that needs to fetch the latest version number from a GitHub API. Or maybe a backend service that calls a payment provider. In Python, you'd grab requests and call it a day. In JavaScript, fetch is built-in. Rust doesn't ship with an HTTP client in the standard library. You need a crate. The community standard is reqwest.
reqwest is the most widely used HTTP client in the Rust ecosystem. It provides a clean, high-level API for making requests. It supports async and blocking modes, JSON serialization, multipart forms, and streaming. It sits on top of hyper, the low-level HTTP implementation, and handles the gritty details of connection management so you don't have to.
How reqwest fits the async model
Rust's concurrency model relies on async/await. This isn't just a syntax sugar; it's a fundamental shift in how you think about I/O. reqwest is async by default. This matches the Rust ecosystem's preference for non-blocking I/O, especially in servers and high-throughput tools.
Think of reqwest like a professional courier service. You don't run to the post office yourself and stand there waiting for a reply. You hand the package to the courier, they give you a ticket, and you go back to work. When the courier finishes the delivery, they call you. In Rust terms, reqwest hands you a Future (the ticket). You call .await on it, which tells the runtime, "Pause this function until the network call finishes, then come back here." The runtime can use that pause time to handle other requests. This lets you handle thousands of concurrent connections with minimal memory overhead.
Minimal example
Start with a simple GET request. You need reqwest and an async runtime. tokio is the most common runtime choice.
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
use reqwest;
// tokio::main sets up the async runtime.
// Without this, you can't use .await in main.
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
// reqwest::get is a convenience function.
// It creates a temporary client, sends the request, and returns the response.
// This returns a Future, not the response itself.
let response = reqwest::get("https://httpbin.org/get").await?;
// Read the response body as text.
// This is another async operation that waits for the full body.
let body = response.text().await?;
println!("Status: {}", response.status());
println!("Body: {}", body);
Ok(())
}
Walkthrough
reqwest::getprepares the request and returns aFuture. Nothing hits the network yet..awaitpolls the future. The runtime takes over, performs the DNS lookup, TCP handshake, TLS negotiation, and sends the request. Your function is suspended.- When the response headers arrive, the future resolves. The function resumes.
response.text()reads the body. This might require multiple network reads..awaithandles that too.- The
?operator propagates errors. If the request fails,mainreturns early with the error.
The Result<(), reqwest::Error> return type is standard. reqwest methods return Result<T, reqwest::Error>. The ? operator unwraps the Ok value or returns the Err immediately.
The Client is the star
The minimal example uses reqwest::get. That function is convenient for scripts, but it has a hidden cost. Every call to reqwest::get creates a new Client under the hood. Creating a client involves setting up connection pools and TLS contexts. Doing this for every request burns CPU and slows down your application.
In real code, you create a Client once and reuse it. The client maintains a pool of idle connections. When you make a request, it reuses an existing connection if one is available. This skips the TCP handshake and TLS negotiation, which are expensive operations.
use reqwest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a single client instance.
// This initializes the connection pool and TLS settings.
let client = reqwest::Client::new();
// Reuse the client for multiple requests.
// The client will reuse connections from the pool.
let response1 = client.get("https://httpbin.org/get").send().await?;
let response2 = client.get("https://httpbin.org/headers").send().await?;
println!("Response 1: {}", response1.status());
println!("Response 2: {}", response2.status());
Ok(())
}
Convention aside: The community calls this the "reuse the client" rule. You'll see Client passed around in structs or via dependency injection in larger apps. Never create a new client inside a loop or a request handler.
Reuse the client. Creating a new one per request burns CPU on handshakes and kills throughput.
Error handling that doesn't lie
HTTP errors are tricky. A network failure is different from a 404 Not Found. reqwest treats them differently. If the server returns a 404, reqwest considers that a successful network operation. The response body contains the error page. reqwest won't return an error automatically.
If you want to treat HTTP error status codes as errors, you must call error_for_status(). This method checks the status code and returns an error if it's 4xx or 5xx.
use reqwest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
// send() returns the response regardless of status code.
// error_for_status() converts 4xx/5xx into an Error.
let response = client
.get("https://httpbin.org/status/404")
.send()
.await?
.error_for_status()?;
// This line won't run if the status is 404.
let body = response.text().await?;
println!("{}", body);
Ok(())
}
When error_for_status() fails, the error contains the status code. You can extract it to provide better error messages.
match client.get("https://httpbin.org/status/500").send().await {
Ok(response) => {
match response.error_for_status() {
Ok(_) => println!("Success"),
Err(e) => {
// Extract the status code from the error.
if let Some(status) = e.status() {
eprintln!("Server error: {}", status);
} else {
eprintln!("Error without status: {}", e);
}
}
}
}
Err(e) => eprintln!("Network error: {}", e),
}
Call error_for_status(). A 404 is a valid response, not a network error. Treat it like one or your error handling is broken.
JSON and Serde
Most APIs return JSON. reqwest integrates with serde to parse JSON directly into Rust structs. Enable the json feature in Cargo.toml (included in the minimal example above).
use reqwest;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct User {
name: String,
email: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
// json() reads the body and deserializes it into User.
// This fails if the JSON is invalid or fields are missing.
let user: User = client
.get("https://jsonplaceholder.typicode.com/users/1")
.header("Accept", "application/json")
.send()
.await?
.error_for_status()?
.json()
.await?;
println!("Found user: {:?}", user);
Ok(())
}
Convention aside: Always set the Accept: application/json header when requesting JSON. Some servers vary their response format based on this header. It's a small detail that prevents hard-to-debug parsing errors.
Pitfalls and compiler errors
Forgetting the runtime
If you try to use reqwest without a runtime, the compiler stops you. You'll see E0277 (trait bound not satisfied) complaining that a future cannot be awaited without a runtime.
error[E0277]: `main` function is not allowed to be `async`
You need #[tokio::main] or an equivalent attribute from another runtime like async-std.
Blocking in async context
reqwest is async. You can't call .await inside a non-async function. If you try, the compiler rejects it.
fn fetch_data() -> String {
// Error: .await is not allowed in a non-async function
reqwest::get("https://example.com").await.unwrap().text().await.unwrap()
}
Make the function async. Or use reqwest::blocking if you truly need synchronous code.
Temporary values and borrows
If you try to borrow data from a response after the response is dropped, you'll hit E0716 (temporary value dropped while borrowed).
let body = reqwest::get("https://example.com")
.await?
.text()
.await?;
// The response is dropped here.
// If you tried to borrow from it, this would fail.
Store the response in a variable if you need to access it multiple times.
Async is a contract. If you call await, you're promising the runtime you'll yield control. Break that promise and the compiler will stop you.
Decision: when to use reqwest vs alternatives
Use reqwest with async features when you're building a server, a CLI tool that can be async, or anything that needs high concurrency. It's the standard choice for most Rust applications.
Use reqwest::blocking when you're writing a simple script where async overhead is unnecessary, or you're integrating with a codebase that doesn't use async. Enable the blocking feature in Cargo.toml.
Reach for ureq when you need a minimal dependency footprint and don't care about async. ureq is a synchronous client with fewer features but a smaller binary size.
Pick hyper when you're building the HTTP server itself or need low-level control over the connection lifecycle. hyper is the foundation reqwest is built on. Use it only when reqwest doesn't give you enough control.