Making HTTP Requests with Rust Reqwest

Build robust, type-safe HTTP clients that take advantage of Rust's memory safety guarantees and zero-cost abstractions

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.

Why Choose reqwest for HTTP Requests in Rust

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.

Basic GET Request
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(&params)
    .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.

File Upload Example
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.

Ready to Build High-Performance Rust Applications?

Our team of Rust experts can help you design and implement robust HTTP clients, API integrations, and full-stack applications that leverage Rust's performance advantages.

Sources

  1. Rust Web Development: Making HTTP Requests - Comprehensive guide covering Reqwest setup, async/blocking APIs, JSON handling, and error patterns
  2. Rust Lang Official: Reqwest Crate Documentation - Official crate documentation with complete API reference for v0.12
  3. Rust HTTP Client Tutorial: From Basics to Production - Production-focused tutorial covering middleware, connection pooling, and retry logic
  4. Oxide Computer: Building with Reqwest - Real-world implementation guide with practical examples and best practices
  5. Rust How-To: Handle HTTP Responses - Focus on streaming responses and memory-efficient handling