HTTP Client and External APIs

Most web applications need to talk to something beyond their own database: a payment processor, a weather service, an email API, a third-party webhook. In Rust, reqwest is the standard HTTP client for these outgoing requests. It provides an async API built on hyper and tokio, with built-in JSON support, TLS, connection pooling, and configurable timeouts.

This section covers building and configuring a reqwest client, designing typed Rust interfaces for external APIs, handling errors and retries, and testing external integrations with mock servers.

Dependencies

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

The json feature on reqwest enables the .json() request builder method and the .json::<T>() response deserialiser. Both rely on serde, which you already have in the stack for form handling and database mapping.

Building a client

reqwest::Client manages a connection pool internally. Create one at application startup and share it through Axum state. Do not create a new client per request, as that discards the connection pool and TLS session cache.

use reqwest::Client;
use std::time::Duration;

let client = Client::builder()
    .timeout(Duration::from_secs(30))
    .connect_timeout(Duration::from_secs(5))
    .pool_max_idle_per_host(10)
    .build()
    .expect("failed to build HTTP client");

timeout sets the total time allowed for a request (connect + send + receive). connect_timeout sets the limit for TCP connection establishment alone. Both prevent your application from hanging indefinitely when an external service is unresponsive.

Add the client to your application state:

#[derive(Clone)]
pub struct AppState {
    pub db: sqlx::PgPool,
    pub http: reqwest::Client,
}

Making requests

GET with JSON response

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct WeatherResponse {
    temperature: f64,
    conditions: String,
    wind_speed: f64,
}

async fn get_weather(
    client: &reqwest::Client,
    city: &str,
) -> Result<WeatherResponse, reqwest::Error> {
    let response = client
        .get("https://api.weather.example.com/v1/current")
        .query(&[("city", city)])
        .send()
        .await?
        .error_for_status()?
        .json::<WeatherResponse>()
        .await?;

    Ok(response)
}

.query() appends URL query parameters. .error_for_status() converts 4xx and 5xx responses into errors before attempting to read the body. Without it, a 404 response would try to deserialise the error body as WeatherResponse and fail with a confusing deserialisation error instead of a clear status code error.

POST with JSON body

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize)]
struct CreateOrderRequest {
    product_id: String,
    quantity: u32,
    customer_email: String,
}

#[derive(Debug, Deserialize)]
struct CreateOrderResponse {
    order_id: String,
    status: String,
    total_cents: u64,
}

async fn create_order(
    client: &reqwest::Client,
    order: &CreateOrderRequest,
) -> Result<CreateOrderResponse, reqwest::Error> {
    client
        .post("https://api.orders.example.com/v1/orders")
        .json(order)
        .send()
        .await?
        .error_for_status()?
        .json::<CreateOrderResponse>()
        .await
}

.json(order) serialises the struct to JSON and sets Content-Type: application/json automatically.

Other response types

Not every API returns JSON. Use .text() for plain text, .bytes() for binary data:

let body = client.get(url).send().await?.text().await?;
let image = client.get(url).send().await?.bytes().await?;

Designing typed API clients

Wrap external API interactions in a dedicated struct. This gives you a single place for base URL configuration, authentication, and error mapping.

use reqwest::Client;
use serde::{Deserialize, Serialize};

pub struct WeatherClient {
    client: Client,
    base_url: String,
    api_key: String,
}

#[derive(Debug, Deserialize)]
pub struct CurrentWeather {
    pub temperature: f64,
    pub conditions: String,
    pub humidity: u32,
}

#[derive(Debug, Deserialize)]
pub struct Forecast {
    pub days: Vec<ForecastDay>,
}

#[derive(Debug, Deserialize)]
pub struct ForecastDay {
    pub date: String,
    pub high: f64,
    pub low: f64,
    pub conditions: String,
}

impl WeatherClient {
    pub fn new(client: Client, base_url: String, api_key: String) -> Self {
        Self {
            client,
            base_url,
            api_key,
        }
    }

    pub async fn current(&self, city: &str) -> Result<CurrentWeather, WeatherError> {
        let response = self
            .client
            .get(format!("{}/v1/current", self.base_url))
            .query(&[("city", city)])
            .bearer_auth(&self.api_key)
            .send()
            .await
            .map_err(WeatherError::Request)?;

        if !response.status().is_success() {
            return Err(WeatherError::status(response).await);
        }

        response.json().await.map_err(WeatherError::Request)
    }

    pub async fn forecast(
        &self,
        city: &str,
        days: u32,
    ) -> Result<Forecast, WeatherError> {
        let response = self
            .client
            .get(format!("{}/v1/forecast", self.base_url))
            .query(&[("city", city), ("days", &days.to_string())])
            .bearer_auth(&self.api_key)
            .send()
            .await
            .map_err(WeatherError::Request)?;

        if !response.status().is_success() {
            return Err(WeatherError::status(response).await);
        }

        response.json().await.map_err(WeatherError::Request)
    }
}

The base_url is configurable so tests can point it at a mock server and production can point it at the real API. The Client is injected rather than created internally, so the application controls timeouts and connection pooling centrally.

Error type for external APIs

Define a dedicated error type that distinguishes between network failures, unexpected status codes, and API-specific error responses:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum WeatherError {
    #[error("request failed: {0}")]
    Request(#[from] reqwest::Error),

    #[error("API error {status}: {message}")]
    Api {
        status: u16,
        message: String,
    },
}

impl WeatherError {
    async fn status(response: reqwest::Response) -> Self {
        let status = response.status().as_u16();
        let message = response
            .text()
            .await
            .unwrap_or_else(|_| "unknown error".to_string());
        Self::Api { status, message }
    }
}

This lets callers match on the error to decide what to do. A 404 from the weather API might mean the city was not found (return a user-facing message). A 500 might mean the service is down (retry or degrade gracefully). A network error means the service was unreachable.

Wiring the client into application state

use std::time::Duration;

let http_client = reqwest::Client::builder()
    .timeout(Duration::from_secs(10))
    .connect_timeout(Duration::from_secs(5))
    .build()
    .expect("failed to build HTTP client");

let weather = WeatherClient::new(
    http_client.clone(),
    std::env::var("WEATHER_API_URL").expect("WEATHER_API_URL required"),
    std::env::var("WEATHER_API_KEY").expect("WEATHER_API_KEY required"),
);

let state = AppState {
    db: pool,
    http: http_client,
    weather,
};

Handlers access the client through state:

async fn weather_page(
    State(state): State<AppState>,
    Path(city): Path<String>,
) -> Result<impl IntoResponse, AppError> {
    let weather = state.weather.current(&city).await?;
    Ok(Html(render_weather(&weather)))
}

JSON serialisation patterns

Renaming fields

External APIs rarely use Rust’s snake_case convention. Use #[serde(rename)] or #[serde(rename_all)] to map between naming styles:

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PaymentResponse {
    payment_id: String,      // maps from "paymentId"
    total_amount: u64,       // maps from "totalAmount"
    currency_code: String,   // maps from "currencyCode"
}

For APIs that use a mix of conventions or have individual oddities:

#[derive(Debug, Deserialize)]
struct ApiResponse {
    #[serde(rename = "ID")]
    id: String,

    #[serde(rename = "created_at")]
    created: String,
}

Optional and default fields

External APIs evolve. Fields get added, deprecated, or become nullable. Use Option<T> for fields that might be absent, and #[serde(default)] for fields with sensible defaults:

#[derive(Debug, Deserialize)]
struct UserProfile {
    pub name: String,
    pub email: String,

    #[serde(default)]
    pub verified: bool,

    pub avatar_url: Option<String>,
}

Option<String> handles both a missing field and an explicit null value. #[serde(default)] fills in false if the field is absent.

Handling API envelope patterns

Many APIs wrap their data in a common envelope:

{
  "status": "ok",
  "data": { "temperature": 22.5, "conditions": "sunny" },
  "metadata": { "request_id": "abc123" }
}

Define a generic wrapper:

#[derive(Debug, Deserialize)]
struct ApiEnvelope<T> {
    status: String,
    data: T,
}

Then deserialise into the envelope and extract the inner value:

let envelope = response
    .json::<ApiEnvelope<CurrentWeather>>()
    .await
    .map_err(WeatherError::Request)?;

Ok(envelope.data)

Flattening nested structures

#[serde(flatten)] merges fields from a nested struct into the parent, useful when an API returns metadata alongside entity fields:

#[derive(Debug, Deserialize)]
struct ApiMetadata {
    request_id: String,
    timestamp: String,
}

#[derive(Debug, Deserialize)]
struct OrderWithMetadata {
    #[serde(flatten)]
    order: Order,

    #[serde(flatten)]
    metadata: ApiMetadata,
}

Authentication with external services

Bearer tokens

The most common pattern. A static API key or an OAuth2 access token passed in the Authorization header:

client
    .get(url)
    .bearer_auth(&api_key)
    .send()
    .await?

reqwest’s .bearer_auth() sets the Authorization: Bearer <token> header.

API keys in headers

Some APIs use a custom header instead of the standard Authorization header:

client
    .get(url)
    .header("X-API-Key", &api_key)
    .send()
    .await?

API keys in query parameters

Less secure (keys appear in server logs and URLs) but some APIs require it:

client
    .get(url)
    .query(&[("api_key", &api_key)])
    .send()
    .await?

Default headers

If every request to an API needs the same authentication header, set it as a default on the client:

use reqwest::header::{HeaderMap, HeaderValue};

let mut headers = HeaderMap::new();
headers.insert(
    "X-API-Key",
    HeaderValue::from_str(&api_key).expect("invalid API key"),
);

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

Default headers are sent on every request made by this client. This avoids repeating authentication on each call and ensures you never accidentally forget it.

OAuth2 token refresh

For APIs that issue short-lived access tokens, store the token and its expiry in your API client and refresh when needed:

use std::sync::Arc;
use tokio::sync::RwLock;

struct TokenState {
    access_token: String,
    expires_at: std::time::Instant,
}

pub struct OAuthClient {
    client: Client,
    base_url: String,
    client_id: String,
    client_secret: String,
    token: Arc<RwLock<Option<TokenState>>>,
}

impl OAuthClient {
    async fn get_token(&self) -> Result<String, reqwest::Error> {
        // Check if current token is still valid
        {
            let token = self.token.read().await;
            if let Some(state) = token.as_ref() {
                if state.expires_at > std::time::Instant::now() {
                    return Ok(state.access_token.clone());
                }
            }
        }

        // Refresh the token
        let response: TokenResponse = self
            .client
            .post(format!("{}/oauth/token", self.base_url))
            .form(&[
                ("grant_type", "client_credentials"),
                ("client_id", &self.client_id),
                ("client_secret", &self.client_secret),
            ])
            .send()
            .await?
            .json()
            .await?;

        let token_state = TokenState {
            access_token: response.access_token.clone(),
            expires_at: std::time::Instant::now()
                + std::time::Duration::from_secs(response.expires_in.saturating_sub(60)),
        };

        *self.token.write().await = Some(token_state);
        Ok(response.access_token)
    }

    pub async fn request_with_auth(
        &self,
        url: &str,
    ) -> Result<reqwest::Response, reqwest::Error> {
        let token = self.get_token().await?;
        self.client.get(url).bearer_auth(&token).send().await
    }
}

#[derive(Deserialize)]
struct TokenResponse {
    access_token: String,
    expires_in: u64,
}

The saturating_sub(60) refreshes the token 60 seconds before it actually expires, avoiding requests that race against expiry.

Error handling for external calls

External HTTP calls fail in ways that database calls and local operations do not. The network is unreliable, third-party services go down, and response formats change without warning.

Timeouts

Always set timeouts. A missing timeout means a single unresponsive external service can exhaust your server’s thread pool:

let client = Client::builder()
    .timeout(Duration::from_secs(10))     // total request timeout
    .connect_timeout(Duration::from_secs(5)) // TCP connect timeout
    .build()?;

For requests where you know the response should be fast, override per-request:

client.get(url)
    .timeout(Duration::from_secs(3))
    .send()
    .await?

Retries with reqwest-middleware

reqwest-middleware wraps reqwest::Client with a middleware chain. reqwest-retry adds automatic retries with exponential backoff for transient failures.

[dependencies]
reqwest-middleware = "0.4"
reqwest-retry = "0.7"
use reqwest_middleware::ClientBuilder;
use reqwest_retry::{
    RetryTransientMiddleware,
    policies::ExponentialBackoff,
};
use std::time::Duration;

let retry_policy = ExponentialBackoff::builder()
    .retry_bounds(
        Duration::from_millis(500),
        Duration::from_secs(10),
    )
    .build_with_max_retries(3);

let client = ClientBuilder::new(
    reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .connect_timeout(Duration::from_secs(5))
        .build()
        .expect("failed to build HTTP client"),
)
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();

The retry middleware automatically retries on:

  • Connection timeouts and resets
  • HTTP 500, 502, 503, 504 (server errors)
  • HTTP 408 (request timeout)
  • HTTP 429 (too many requests)

It does not retry 4xx client errors (except 408 and 429), because those indicate a problem with the request itself.

The middleware client (ClientWithMiddleware) has the same API as reqwest::Client for making requests. If your API client struct wraps the client, swap reqwest::Client for reqwest_middleware::ClientWithMiddleware:

use reqwest_middleware::ClientWithMiddleware;

pub struct WeatherClient {
    client: ClientWithMiddleware,
    base_url: String,
    api_key: String,
}

Circuit breaking

A circuit breaker prevents your application from hammering a service that is already failing. After a threshold of consecutive failures, it “opens” the circuit and fails requests immediately for a cooldown period, then allows a probe request through to check if the service has recovered.

There is no dominant circuit breaker crate in the Rust ecosystem. For most applications, the combination of timeouts + retries with backoff is sufficient. The backoff itself acts as a partial circuit breaker: each retry waits longer, reducing pressure on the failing service.

For operations where reliability matters beyond what retries provide (payment processing, order fulfilment, webhook delivery), use Restate for durable execution. Restate persists the call, retries across process restarts, and provides exactly-once semantics. This is a fundamentally stronger guarantee than in-process retries, which are lost if the application crashes.

Mapping external errors to AppError

Bridge your API client errors to the application’s error type:

impl From<WeatherError> for AppError {
    fn from(err: WeatherError) -> Self {
        match err {
            WeatherError::Api { status, message } if status == 404 => {
                AppError::NotFound(format!("weather data: {message}"))
            }
            WeatherError::Api { status, message } => {
                tracing::error!(status, message, "external API error");
                AppError::BadGateway("external service returned an error".to_string())
            }
            WeatherError::Request(err) => {
                tracing::error!(error = ?err, "external request failed");
                AppError::BadGateway("external service unavailable".to_string())
            }
        }
    }
}

502 Bad Gateway is the appropriate HTTP status when your server is acting as a gateway to an upstream service and that service fails. Add a BadGateway variant to your AppError if you don’t have one.

Testing external API integrations

External HTTP calls are one of the most important things to test and one of the easiest to get wrong. wiremock starts a real HTTP server in your test process and lets you define expected requests and canned responses.

[dev-dependencies]
wiremock = "0.6"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

A complete test

This test exercises the WeatherClient against a mock server:

use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path, query_param, header};

#[tokio::test]
async fn test_current_weather() {
    // Start a mock HTTP server
    let mock_server = MockServer::start().await;

    // Define the expected request and response
    Mock::given(method("GET"))
        .and(path("/v1/current"))
        .and(query_param("city", "london"))
        .and(header("authorization", "Bearer test-key"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "temperature": 18.5,
                "conditions": "cloudy",
                "humidity": 72
            })),
        )
        .expect(1)
        .mount(&mock_server)
        .await;

    // Create the client pointing at the mock server
    let client = WeatherClient::new(
        reqwest::Client::new(),
        mock_server.uri(),
        "test-key".to_string(),
    );

    // Call the method under test
    let weather = client.current("london").await.unwrap();

    assert_eq!(weather.temperature, 18.5);
    assert_eq!(weather.conditions, "cloudy");
    assert_eq!(weather.humidity, 72);
}

The mock server binds to a random available port on localhost. mock_server.uri() returns the base URL (e.g., http://127.0.0.1:54321). Because WeatherClient accepts a configurable base_url, no production code needs to change.

.expect(1) asserts that the mock was called exactly once. If the test ends without the expected call count, wiremock panics with a clear message showing which mocks were not satisfied.

Testing error responses

#[tokio::test]
async fn test_city_not_found() {
    let mock_server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1/current"))
        .respond_with(
            ResponseTemplate::new(404)
                .set_body_string("city not found"),
        )
        .mount(&mock_server)
        .await;

    let client = WeatherClient::new(
        reqwest::Client::new(),
        mock_server.uri(),
        "test-key".to_string(),
    );

    let err = client.current("atlantis").await.unwrap_err();
    match err {
        WeatherError::Api { status, .. } => assert_eq!(status, 404),
        _ => panic!("expected Api error, got {err:?}"),
    }
}

Testing timeout behaviour

#[tokio::test]
async fn test_timeout() {
    let mock_server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1/current"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(serde_json::json!({"temperature": 20.0}))
                .set_delay(std::time::Duration::from_secs(10)),
        )
        .mount(&mock_server)
        .await;

    let client = WeatherClient::new(
        reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(1))
            .build()
            .unwrap(),
        mock_server.uri(),
        "test-key".to_string(),
    );

    let err = client.current("london").await.unwrap_err();
    assert!(matches!(err, WeatherError::Request(_)));
}

wiremock’s .set_delay() on the response template simulates a slow upstream service. The test verifies that the client’s timeout configuration works as expected.

Gotchas

Reuse the Client. Each reqwest::Client holds a connection pool. Creating a new client per request means no connection reuse, no TLS session caching, and a DNS lookup on every call. Build one client at startup and share it.

Always set timeouts. reqwest has no default timeout. Without one, a request to an unresponsive host blocks the Tokio task indefinitely. Set both timeout (total) and connect_timeout (connection establishment) on the client builder.

Call .error_for_status() or check status manually. A 404 or 500 response from an external API is not a reqwest error by default. The request succeeded at the HTTP level. If you skip the status check, you’ll get a confusing deserialisation error when serde tries to parse an error body as your response type.

Watch for #[serde(deny_unknown_fields)]. It’s tempting to add this to be strict about API responses, but external APIs add new fields all the time. Unknown fields should be silently ignored (serde’s default) to avoid breaking your application when a third-party adds a field to their response.

Token and credential storage. API keys, client secrets, and OAuth2 credentials belong in environment variables or a secrets manager, not in code. The Configuration and Secrets section covers this in detail.

Log external failures, but not credentials. When logging failed external requests for debugging, ensure you are not writing API keys, bearer tokens, or request bodies containing sensitive data to your logs. Log the URL, status code, and error message. Skip headers and bodies unless you have confirmed they contain no secrets.