In the previous tutorial, we learned Serde for serialization. Now we use those skills to make HTTP requests with Reqwest — the most popular HTTP client in Rust.

Most real applications talk to APIs. Whether you fetch data, send forms, or call microservices, you need an HTTP client. Reqwest makes this easy while staying fully async.

Setting Up

Add these dependencies to your Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

The "json" feature on reqwest enables built-in JSON parsing with Serde.

Your First GET Request

use reqwest;

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let body = reqwest::get("https://httpbin.org/get")
        .await?
        .text()
        .await?;

    println!("{}", body);
    Ok(())
}

reqwest::get() is a convenience function. It creates a temporary client, sends a GET request, and returns the response.

Notice there are two .await points:

  1. .await? on the request — sends and waits for response headers
  2. .text().await? — reads the response body

The body is not downloaded with the headers. This lets you check the status code before reading a potentially large body.

Parsing JSON Responses

Most APIs return JSON. Use .json() to parse directly into a Rust struct:

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug, Deserialize)]
struct HttpBinResponse {
    url: String,
    headers: HashMap<String, String>,
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let response: HttpBinResponse = reqwest::get("https://httpbin.org/get")
        .await?
        .json()
        .await?;

    println!("URL: {}", response.url);
    Ok(())
}

The .json() method uses Serde’s Deserialize trait. Field names must match the JSON keys, or use #[serde(rename)].

The Reqwest Client

For production code, create a reqwest::Client and reuse it. The client manages a connection pool, which is much more efficient than creating a new connection for each request:

use std::time::Duration;

fn create_client() -> reqwest::Client {
    reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .user_agent("rust-tutorial/1.0")
        .build()
        .expect("Failed to create HTTP client")
}

Create the client once at startup and share it across your application. It is Clone and thread-safe.

GET with Query Parameters

Add query parameters with .query():

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug, Deserialize)]
struct SearchResponse {
    args: HashMap<String, String>,
    url: String,
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let client = reqwest::Client::new();
    let response: SearchResponse = client
        .get("https://httpbin.org/get")
        .query(&[("page", "1"), ("limit", "10")])
        .send()
        .await?
        .json()
        .await?;

    println!("URL: {}", response.url);
    // https://httpbin.org/get?page=1&limit=10
    Ok(())
}

The .query() method URL-encodes the parameters automatically.

POST Requests with JSON

Send JSON data with .json():

use serde::{Serialize, Deserialize};

#[derive(Serialize)]
struct CreateUser {
    name: String,
    email: String,
}

#[derive(Debug, Deserialize)]
struct PostResponse {
    url: String,
    json: Option<serde_json::Value>,
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let user = CreateUser {
        name: "Alex".to_string(),
        email: "alex@example.com".to_string(),
    };

    let client = reqwest::Client::new();
    let response: PostResponse = client
        .post("https://httpbin.org/post")
        .json(&user)
        .send()
        .await?
        .json()
        .await?;

    println!("Posted: {:?}", response.json);
    Ok(())
}

The .json() method on the request builder serializes your struct and sets the Content-Type: application/json header automatically.

Other POST Body Types

// Form data
let response = client
    .post("https://httpbin.org/post")
    .form(&[("username", "alex"), ("password", "secret")])
    .send()
    .await?;

// Raw string body
let response = client
    .post("https://httpbin.org/post")
    .body("raw text body")
    .send()
    .await?;

Custom Headers

Add headers to your request:

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let client = reqwest::Client::new();
    let response = client
        .get("https://httpbin.org/headers")
        .header("X-Custom-Header", "my-value")
        .header("Accept", "application/json")
        .send()
        .await?;

    println!("Status: {}", response.status());
    Ok(())
}

For headers on every request, set them on the client builder:

use reqwest::header;

let mut headers = header::HeaderMap::new();
headers.insert("Authorization", "Bearer my-token".parse().unwrap());
headers.insert("Accept", "application/json".parse().unwrap());

let client = reqwest::Client::builder()
    .default_headers(headers)
    .build()?;

Checking Response Status

Always check the status code before processing the response:

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let response = reqwest::get("https://httpbin.org/status/200").await?;
    let status = response.status();

    println!("Status: {}", status);
    println!("Success: {}", status.is_success());        // 2xx
    println!("Client error: {}", status.is_client_error()); // 4xx
    println!("Server error: {}", status.is_server_error()); // 5xx

    Ok(())
}

error_for_status()

Convert non-success status codes into errors:

let body = reqwest::get("https://httpbin.org/status/404")
    .await?
    .error_for_status()?  // Returns Err for 4xx and 5xx
    .text()
    .await?;

Timeouts

Set timeouts to avoid hanging on slow servers:

use std::time::Duration;

// Timeout on the client (applies to all requests)
let client = reqwest::Client::builder()
    .timeout(Duration::from_secs(5))
    .build()?;

// Timeout on a single request
let response = client
    .get("https://httpbin.org/delay/10")
    .timeout(Duration::from_secs(3))
    .send()
    .await?;

If the timeout is reached, you get a reqwest::Error with is_timeout() == true.

Error Handling

For production code, create a custom error type using thiserror (from the previous tutorial):

use thiserror::Error;

#[derive(Debug, Error)]
enum ApiError {
    #[error("network error: {0}")]
    Network(#[from] reqwest::Error),

    #[error("not found: {0}")]
    NotFound(String),

    #[error("server error: status {0}")]
    ServerError(u16),

    #[error("parse error: {0}")]
    Parse(String),
}

async fn fetch_user(client: &reqwest::Client, id: u64) -> Result<User, ApiError> {
    let url = format!("https://api.example.com/users/{}", id);
    let response = client.get(&url).send().await?;

    match response.status().as_u16() {
        200 => {
            let user: User = response.json().await?;
            Ok(user)
        }
        404 => Err(ApiError::NotFound(format!("user {}", id))),
        code => Err(ApiError::ServerError(code)),
    }
}

The #[from] attribute lets the ? operator convert reqwest::Error into ApiError automatically.

Building an API Client

Here is how you structure an API client in a real application:

use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;

#[derive(Debug, Error)]
enum GithubError {
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),

    #[error("user not found: {0}")]
    NotFound(String),

    #[error("rate limited")]
    RateLimited,

    #[error("server error: {0}")]
    Server(u16),
}

#[derive(Debug, Deserialize)]
struct GithubUser {
    login: String,
    name: Option<String>,
    public_repos: u32,
}

struct GithubClient {
    client: reqwest::Client,
    base_url: String,
}

impl GithubClient {
    fn new() -> Self {
        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(10))
            .user_agent("rust-tutorial/1.0")
            .build()
            .expect("Failed to create client");

        Self {
            client,
            base_url: "https://api.github.com".to_string(),
        }
    }

    async fn get_user(&self, username: &str) -> Result<GithubUser, GithubError> {
        let url = format!("{}/users/{}", self.base_url, username);
        let response = self.client.get(&url).send().await?;

        match response.status().as_u16() {
            200 => {
                let user: GithubUser = response.json().await?;
                Ok(user)
            }
            404 => Err(GithubError::NotFound(username.to_string())),
            429 => Err(GithubError::RateLimited),
            code => Err(GithubError::Server(code)),
        }
    }
}

#[tokio::main]
async fn main() {
    let github = GithubClient::new();

    match github.get_user("rust-lang").await {
        Ok(user) => {
            println!("Login: {}", user.login);
            println!("Name: {:?}", user.name);
            println!("Repos: {}", user.public_repos);
        }
        Err(e) => eprintln!("Error: {}", e),
    }
}

Key patterns:

  • Store the Client in a struct and reuse it
  • Set default headers and timeouts on the client builder
  • Use a base URL to avoid repeating it
  • Return Result with a custom error type
  • Match on status codes for different error handling

PUT, PATCH, and DELETE

Other HTTP methods work the same way:

// PUT — replace a resource
let response = client
    .put("https://api.example.com/users/1")
    .json(&updated_user)
    .send()
    .await?;

// PATCH — partial update
let response = client
    .patch("https://api.example.com/users/1")
    .json(&partial_update)
    .send()
    .await?;

// DELETE — remove a resource
let response = client
    .delete("https://api.example.com/users/1")
    .send()
    .await?;

Common Mistakes

Mistake 1: Creating a New Client for Each Request

// BAD: creates a new connection pool each time
async fn bad_get(url: &str) -> Result<String, reqwest::Error> {
    let response = reqwest::get(url).await?;
    response.text().await
}

// GOOD: reuse the client
async fn good_get(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> {
    let response = client.get(url).send().await?;
    response.text().await
}

Mistake 2: Not Setting Timeouts

// BAD: no timeout — request can hang forever
let client = reqwest::Client::new();

// GOOD: always set a timeout
let client = reqwest::Client::builder()
    .timeout(Duration::from_secs(10))
    .build()?;

Mistake 3: Ignoring Status Codes

// BAD: assumes success
let response = client.get(url).send().await?;
let data: User = response.json().await?;  // Fails on error responses

// GOOD: check status first
let response = client.get(url).send().await?;
if !response.status().is_success() {
    return Err(ApiError::Server(response.status().as_u16()));
}
let data: User = response.json().await?;

Summary

ConceptPurpose
reqwest::get(url)Quick GET request
client.get(url).send()GET with reusable client
client.post(url).json(&data)POST with JSON body
.query(&params)Add URL query parameters
.header(name, value)Add request header
.timeout(duration)Set request timeout
.json().await?Parse response as JSON
.text().await?Read response as string
.status()Get HTTP status code
.error_for_status()Convert 4xx/5xx to errors
Client::builder()Create reusable client with defaults

Source Code

Find the complete code on GitHub: tutorial-21-reqwest

What’s Next?

In this tutorial, we learned HTTP requests with Reqwest. We covered GET, POST, headers, timeouts, error handling, and building a reusable API client.

In the next tutorial, we will build the other side — a REST API server with Axum, handling routes, JSON requests, shared state, and middleware.

Next: Rust Tutorial #22: Web API with Axum