Introduction
Modern web applications depend heavily on HTTP communication to interact with APIs, fetch external data, and integrate with third-party services. Rust has emerged as a powerful language for building high-performance web applications, and reqwest stands as the de facto standard HTTP client library for the Rust ecosystem. Whether you are building a Next.js API route handler that needs to call external services, or constructing a backend service that integrates with multiple REST APIs, reqwest provides the functionality you need with the performance you expect from Rust.
This guide explores how to leverage reqwest to build robust, type-safe HTTP clients that take advantage of Rust's memory safety guarantees and zero-cost abstractions. From basic GET requests to advanced configurations suitable for production workloads, you will learn everything needed to implement effective HTTP communication in your Rust applications.
For teams working across multiple technologies, understanding how to build reliable HTTP clients is essential for full-stack web development projects that span frontend and backend components.
The features that make reqwest the de facto standard HTTP client
Async and Blocking Support
Use the same library for asyncTokio-based applications and simple blocking scripts with the blocking feature flag.
Automatic JSON Handling
Seamless integration with serde for automatic serialization and deserialization of request and response bodies.
Flexible TLS Options
Choose between system-native TLS or rustls based on your security requirements and performance needs.
HTTP/2 Support
Native HTTP/2 support with proper negotiation for improved performance when servers support it.
Multipart Uploads
Built-in support for multipart form data makes file uploads straightforward and type-safe.
Cookie Management
Integrated cookie jar with configurable policies for session management and authentication flows.
Setting Up Your Rust Project with Reqwest
Getting started with reqwest requires configuring your Cargo.toml with the appropriate dependencies and feature flags. The library offers a modular design that lets you include only the features your application needs.
Cargo.toml Configuration
[dependencies]
reqwest = { version = "0.12", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Understanding Feature Flags
The json feature is essential for most applications as it enables automatic JSON serialization and deserialization through integration with Serde, eliminating boilerplate code for common patterns. This feature automatically handles the conversion between Rust data structures and JSON payloads, making it an essential tool for type-safe API development.
The blocking feature provides a synchronous API that blocks the current thread, perfect for CLI tools, simple scripts, or when async complexity isn't warranted. When building web services or applications that handle many concurrent connections, omit this flag and use the default async API instead.
For TLS configuration, you can choose between native-tls for system-level certificate stores (Windows Security, macOS Security, OpenSSL on Linux) or rustls-tls for a pure-Rust implementation. The rustls option provides greater flexibility for custom certificate authorities and specific cipher suites.
Additional compression features like gzip, brotli, and zstd enable automatic decompression of response bodies, which is essential when working with modern APIs that use content encoding. The cookies feature enables automatic cookie handling, maintaining session state across requests automatically.
Configuring the Async Runtime
Reqwest requires an async runtime to function, with Tokio being the most common choice. The simplest approach uses the #[tokio::main] attribute macro, which creates a multi-threaded runtime for your application:
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
let response = client.get("https://api.example.com").send().await?;
println!("Status: {}", response.status());
Ok(())
}
For more control over the runtime configuration, you can use tokio::runtime::Builder to customize thread count, execution mode, and other parameters:
use tokio::runtime::Builder;
fn main() {
let runtime = Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let client = reqwest::Client::new();
// Your async code here
});
}
This flexibility allows you to optimize runtime behavior for your specific workload, whether you need high throughput for many concurrent requests or minimal resource usage for simple applications. When building production web applications, proper async runtime configuration is critical for achieving optimal performance.
Making Your First HTTP Request
Reqwest's API is designed for simplicity and ergonomics. Making your first HTTP request requires just a few lines of code, yet the library provides depth for complex use cases.
Simple GET Request
use reqwest;
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let response = reqwest::get("https://httpbin.org/ip").await?;
let ip_address = response.text().await?;
println!("Your IP: {}", ip_address);
Ok(())
}
Working with Response Data
After sending a request, you need to handle the response appropriately. The Response struct provides methods for checking status codes, accessing headers, and reading the body in various formats.
Status code checking is essential for proper error handling. HTTP responses use numeric status codes to indicate success, redirection, or error conditions:
let response = client.get("https://api.example.com/data").send().await?;
// Check if request succeeded (2xx status codes)
if response.status().is_success() {
println!("Request successful!");
} else if response.status().is_client_error() {
eprintln!("Client error: {}", response.status());
} else if response.status().is_server_error() {
eprintln!("Server error: {}", response.status());
}
Headers provide metadata about the response and can be accessed using case-insensitive keys:
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok());
let content_length = response
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
Reqwest provides multiple methods for reading the response body depending on your needs. The text() method returns a UTF-8 string, bytes() provides raw byte data, and json() automatically deserializes JSON into Rust types using Serde:
// Read as text
let text = response.text().await?;
// Read as bytes (for binary data)
let bytes = response.bytes().await?;
// Read and deserialize JSON directly
let data: serde_json::Value = response.json().await?;
For large responses, consider streaming to avoid loading the entire body into memory at once. This approach is particularly important when working with API integrations that may return large datasets.
1use reqwest;2 3#[tokio::main]4async fn main() -> Result<(), reqwest::Error> {5 // Make a simple GET request6 let response = 7 reqwest::get(8 "https://httpbin.org/ip"9 ).await?;10 11 // Read response as text12 let ip_address = 13 response.text().await?;14 15 println!("Your IP: {}", ip_address);16 Ok(())17}Building Type-Safe API Clients
One of Rust's greatest strengths is its type system, and reqwest integrates seamlessly with serde to provide compile-time guarantees about your API interactions.
Defining Request and Response Structures
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: u32,
pub name: String,
pub email: String,
}
#[derive(Debug, Serialize)]
pub struct CreateUserRequest {
pub name: String,
pub email: String,
}
Automatic JSON Serialization
When you call the .json() method on a response, reqwest automatically handles the deserialization process using Serde. This approach provides type safety and compile-time checking, ensuring your code correctly handles the structure of incoming data. If the JSON structure doesn't match your Rust struct, you'll get a clear error at runtime, making debugging straightforward.
For sending data, the .json() method on a request builder automatically serializes your data structure:
let create_request = CreateUserRequest {
name: "John Doe".to_string(),
email: "[email protected]".to_string(),
};
let response = client
.post("https://api.example.com/users")
.json(&create_request)
.send()
.await?;
The importance of proper error handling cannot be overstated when working with external APIs. Network failures, malformed responses, and API errors can all cause failures. A robust error type helps distinguish between different failure modes, which is essential for maintainable web applications:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiClientError {
#[error("HTTP request failed: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("API error: {status} - {message}")]
Api { status: reqwest::StatusCode, message: String },
#[error("Invalid response format")]
InvalidResponse,
}
For API error responses that include structured error details in the body, you can define error response types and handle them explicitly:
#[derive(Debug, Deserialize)]
pub struct ApiErrorResponse {
pub error: String,
pub code: u16,
}
match response.json::<ApiErrorResponse>().await {
Ok(error_body) => {
return Err(ApiClientError::Api {
status: response.status(),
message: error_body.error,
});
}
Err(_) => {
return Err(ApiClientError::InvalidResponse);
}
}
Robust Error Handling with thiserror
Production applications require comprehensive error handling that distinguishes between different failure modes.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiClientError {
#[error("HTTP request failed: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("API error: {message} (code: {code})")]
Api { message: String, code: u16 },
#[error("Invalid response format")]
InvalidResponse,
}
Understanding Error Categories
Reqwest's error system distinguishes between network errors (connection failures, timeouts), HTTP errors (non-success status codes), and decoding errors (JSON parsing failures). Using the error's predicate methods allows you to respond appropriately to different failure modes:
match client.get(url).send().await {
Ok(response) => {
if response.status().is_success() {
// Process successful response
} else {
eprintln!("HTTP Error: {}", response.status());
}
}
Err(e) => {
if e.is_timeout() {
eprintln!("Request timed out - consider increasing timeout");
} else if e.is_connect() {
eprintln!("Connection failed - check network or DNS");
} else if e.is_decode() {
eprintln!("Failed to decode response - check content type");
} else if e.is_status() {
if let Some(status) = e.status() {
eprintln!("Server returned error: {}", status);
}
}
}
}
Implementing Retry Strategies
For production applications that need to handle transient failures gracefully, implementing retry logic with exponential backoff is essential. A robust retry implementation considers which errors are retryable:
async fn retry_with_backoff(
client: &reqwest::Client,
url: &str,
max_retries: u32,
) -> Result<reqwest::Response, reqwest::Error> {
let mut retries = 0;
loop {
match client.get(url).send().await {
Ok(response) => {
// Retry on server errors only
if response.status().is_server_error() && retries < max_retries {
let delay = Duration::from_millis(500 * 2_u32.pow(retries));
tokio::time::sleep(delay).await;
retries += 1;
continue;
}
return Ok(response);
}
Err(e) => {
if retries >= max_retries {
return Err(e);
}
// Only retry on network errors
if !e.is_network() {
return Err(e);
}
let delay = Duration::from_millis(500 * 2_u32.pow(retries));
tokio::time::sleep(delay).await;
retries += 1;
}
}
}
}
Working with Forms and File Uploads
Reqwest simplifies form submissions and file uploads with dedicated multipart support.
URL-Encoded Form Submissions
let params = [
("username", "john"),
("password", "secret")
];
let response = client
.post("https://api.example.com/login")
.form(¶ms)
.send()
.await?;
Building Multipart Forms
For complex form submissions including file uploads, reqwest provides the multipart feature which enables the multipart module for constructing multipart form data. This approach automatically handles the proper content-type headers and boundary generation:
use reqwest::multipart;
// Create multipart form with text fields and files
let form = multipart::Form::new()
.text("description", "User profile image")
.text("category", "avatar")
.file("image", "path/to/profile.png")?;
let response = client
.post("https://api.example.com/upload")
.multipart(form)
.send()
.await?;
Async File Handling
When uploading files asynchronously, you can stream file contents rather than loading them entirely into memory. This approach is essential for large file uploads and is a key consideration when building robust file upload systems:
use tokio::fs::File;
use tokio::io::AsyncReadExt;
async fn upload_file(
client: &reqwest::Client,
path: &str,
) -> Result<(), reqwest::Error> {
let mut file = File::open(path).await?;
let mut contents = Vec::new();
file.read_to_end(&mut contents).await?;
let form = multipart::Form::new()
.part("file", multipart::Part::bytes(contents))
.text("filename", path);
let response = client
.post("https://api.example.com/upload")
.multipart(form)
.send()
.await?;
Ok(response.json().await?)
}
For very large files, consider streaming directly from the file using tokio::io::BufReader to minimize memory usage during the upload process.
1use reqwest::multipart;2 3// Create multipart form4let form = multipart::Form::new()5 .text("description", 6 "My file upload")7 .file("file", 8 "path/to/file.txt")?;9 10// Send the upload11let response = client12 .post("https://api.example.com/upload")13 .multipart(form)14 .send()15 .await?;16 17// Handle response18let result = response19 .json::<UploadResult>()20 .await?;reqwest by the Numbers
300M+
Total Downloads
0.12
Current Major Version
100%
Rust Ownership Model
6
Major HTTP Methods Supported
Frequently Asked Questions
Conclusion
Reqwest provides a powerful, ergonomic foundation for HTTP communication in Rust applications. Its type-safe design, seamless serde integration, and flexible feature set make it the ideal choice for everything from simple API calls to complex microservice architectures.
By following the patterns and practices outlined in this guide, you can build HTTP clients that are reliable, performant, and maintainable. The investment in proper error handling, type-safe data structures, and connection management pays dividends as your application scales.
Next Steps
- Build a complete API client for a real-world service
- Explore integration with web frameworks like Axum or Actix-web
- Implement observability middleware for request logging and metrics
- Experiment with HTTP/2 connection management for high-throughput scenarios
Building robust HTTP clients is just one aspect of creating high-performance web applications. At Digital Thrive, our team specializes in designing and implementing full-stack solutions that leverage Rust's performance advantages while maintaining clean, maintainable codebases. Whether you need help with API integration design, performance optimization, or building microservices from scratch, we have the expertise to bring your vision to life.
Sources
- Rust Web Development: Making HTTP Requests - Comprehensive guide covering Reqwest setup, async/blocking APIs, JSON handling, and error patterns
- Rust Lang Official: Reqwest Crate Documentation - Official crate documentation with complete API reference for v0.12
- Rust HTTP Client Tutorial: From Basics to Production - Production-focused tutorial covering middleware, connection pooling, and retry logic
- Oxide Computer: Building with Reqwest - Real-world implementation guide with practical examples and best practices
- Rust How-To: Handle HTTP Responses - Focus on streaming responses and memory-efficient handling