Why build a dedicated API client?

It is tempting to scatter reqwest::get(...) calls throughout an application. That works for a prototype, but it becomes difficult to maintain once you need:

  • consistent headers and authentication
  • typed request and response models
  • retry-friendly error handling
  • testable business logic
  • support for multiple endpoints

A dedicated client centralizes these concerns. Instead of repeating HTTP setup and JSON parsing everywhere, you expose a small Rust API that returns domain types.

What we are building

We will implement a client for a fictional issue-tracking service with endpoints such as:

  • GET /issues/{id} to fetch one issue
  • GET /issues?status=open to list issues
  • POST /issues to create a new issue

The same pattern works for many real APIs, including internal REST services.

Dependencies

Add these crates to Cargo.toml:

[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
thiserror = "2"

reqwest provides the HTTP client, serde handles JSON encoding and decoding, tokio powers async execution, and thiserror helps define a clean error type.

Define your data models first

Typed models are the foundation of a reliable API client. Start by describing the JSON structures you expect to send and receive.

use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct Issue {
    pub id: u64,
    pub title: String,
    pub status: String,
    pub assignee: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct NewIssue<'a> {
    pub title: &'a str,
    pub description: &'a str,
}

A few best practices are worth noting:

  • Use Option<T> for fields that may be absent.
  • Prefer owned String when the data must outlive the request scope.
  • Use borrowed &str in request payloads when you only need temporary serialization.
  • Keep model names close to the API domain, not the transport layer.

If the API uses different field names, serde can map them with #[serde(rename = "...")].

Design a client wrapper

A wrapper type gives you a single place to store configuration such as the base URL, authentication token, and shared HTTP client.

use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use reqwest::Client;

pub struct IssueTrackerClient {
    base_url: String,
    client: Client,
}

impl IssueTrackerClient {
    pub fn new(base_url: impl Into<String>, token: impl AsRef<str>) -> Result<Self, reqwest::header::InvalidHeaderValue> {
        let mut headers = HeaderMap::new();
        let auth_value = format!("Bearer {}", token.as_ref());
        headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));

        let client = Client::builder()
            .default_headers(headers)
            .build()
            .expect("failed to build HTTP client");

        Ok(Self {
            base_url: base_url.into(),
            client,
        })
    }
}

This structure keeps the HTTP client reusable. Reusing a single reqwest::Client is important because it pools connections and avoids unnecessary setup overhead.

Why this shape works well

ConcernWhere it lives
Base endpointbase_url
Authenticationdefault headers
Connection poolingshared reqwest::Client
JSON encoding/decodingserde models
Endpoint logicmethods on IssueTrackerClient

This separation makes the client easy to test and extend.

Implement a typed GET request

Let’s add a method for fetching a single issue by ID.

use thiserror::Error;

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

    #[error("unexpected status code: {0}")]
    Status(reqwest::StatusCode),
}

impl IssueTrackerClient {
    pub async fn get_issue(&self, id: u64) -> Result<Issue, ApiError> {
        let url = format!("{}/issues/{}", self.base_url, id);

        let response = self.client.get(url).send().await?;

        if !response.status().is_success() {
            return Err(ApiError::Status(response.status()));
        }

        let issue = response.json::<Issue>().await?;
        Ok(issue)
    }
}

This method does three things:

  1. builds the endpoint URL
  2. sends the request
  3. validates the status code before deserializing JSON

That order matters. If the server returns an error page or a structured error object, trying to deserialize it into Issue would produce a confusing failure. Checking the status first gives you a clearer control flow.

Handle query parameters cleanly

Listing issues usually requires filters such as status, assignee, or pagination. reqwest supports query serialization directly.

use serde::Serialize;

#[derive(Debug, Serialize)]
pub struct IssueQuery<'a> {
    pub status: Option<&'a str>,
    pub assignee: Option<&'a str>,
    pub page: Option<u32>,
}

impl IssueTrackerClient {
    pub async fn list_issues(&self, query: IssueQuery<'_>) -> Result<Vec<Issue>, ApiError> {
        let url = format!("{}/issues", self.base_url);

        let response = self
            .client
            .get(url)
            .query(&query)
            .send()
            .await?;

        if !response.status().is_success() {
            return Err(ApiError::Status(response.status()));
        }

        Ok(response.json::<Vec<Issue>>().await?)
    }
}

Using a dedicated query struct is better than manually concatenating strings. It reduces encoding bugs and makes the supported filters explicit.

Create resources with POST

For write operations, serialize a request body into JSON and send it with POST.

impl IssueTrackerClient {
    pub async fn create_issue(&self, issue: &NewIssue<'_>) -> Result<Issue, ApiError> {
        let url = format!("{}/issues", self.base_url);

        let response = self
            .client
            .post(url)
            .json(issue)
            .send()
            .await?;

        if !response.status().is_success() {
            return Err(ApiError::Status(response.status()));
        }

        Ok(response.json::<Issue>().await?)
    }
}

This pattern is simple and idiomatic:

  • .json(issue) serializes the payload
  • the Content-Type: application/json header is already set
  • the response is deserialized into a typed model

Add a practical error strategy

A production client should distinguish between transport errors, server errors, and data problems. The earlier ApiError type is a good start, but you can improve it by capturing response bodies for debugging.

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

    #[error("unexpected status code: {status}, body: {body}")]
    Http {
        status: reqwest::StatusCode,
        body: String,
    },
}

Then read the body when the status is not successful:

impl IssueTrackerClient {
    async fn send_json<T: serde::de::DeserializeOwned>(
        &self,
        request: reqwest::RequestBuilder,
    ) -> Result<T, ApiError> {
        let response = request.send().await?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            return Err(ApiError::Http { status, body });
        }

        Ok(response.json::<T>().await?)
    }
}

This helper reduces duplication and gives you a single place to enforce response handling rules.

Refactor toward reuse

Once you have multiple endpoints, a small helper like send_json becomes valuable. Here is how the client methods look after refactoring:

impl IssueTrackerClient {
    pub async fn get_issue(&self, id: u64) -> Result<Issue, ApiError> {
        let url = format!("{}/issues/{}", self.base_url, id);
        self.send_json(self.client.get(url)).await
    }

    pub async fn list_issues(&self, query: IssueQuery<'_>) -> Result<Vec<Issue>, ApiError> {
        let url = format!("{}/issues", self.base_url);
        self.send_json(self.client.get(url).query(&query)).await
    }

    pub async fn create_issue(&self, issue: &NewIssue<'_>) -> Result<Issue, ApiError> {
        let url = format!("{}/issues", self.base_url);
        self.send_json(self.client.post(url).json(issue)).await
    }
}

This version is easier to maintain because endpoint methods now focus on intent, not HTTP boilerplate.

Use the client in an application

Here is a complete example of calling the client from main.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = IssueTrackerClient::new("https://api.example.com", "secret-token")?;

    let new_issue = NewIssue {
        title: "Fix login timeout",
        description: "Users are logged out after 30 seconds on slow networks.",
    };

    let created = client.create_issue(&new_issue).await?;
    println!("Created issue: {:?}", created);

    let fetched = client.get_issue(created.id).await?;
    println!("Fetched issue: {:?}", fetched);

    Ok(())
}

This is the main benefit of typed clients: the application code reads like domain logic rather than HTTP plumbing.

Testing the client

For client code, tests should verify request construction and response handling. A common approach is to use a mock HTTP server such as wiremock or httpmock. That lets you simulate real API behavior without depending on an external service.

You should test at least these cases:

  • successful JSON response
  • non-2xx status code
  • malformed JSON
  • missing optional fields
  • query parameter serialization

A good test suite catches changes in API contracts early, especially when the remote service evolves independently.

Best practices for production use

A few habits make API clients much more robust:

  • Reuse the HTTP client: create one reqwest::Client per application or per API.
  • Keep models explicit: avoid untyped serde_json::Value unless the schema is truly dynamic.
  • Validate status codes before parsing: error bodies are often not the same shape as success bodies.
  • Separate transport and domain errors: callers should know whether a failure was network-related or API-related.
  • Support timeouts: configure reasonable request timeouts for real services.
  • Log carefully: include status codes and request IDs, but avoid leaking secrets.

Common extension points

FeatureTypical approach
Paginationexpose page and limit in query structs
Retrieswrap requests with retry logic for transient failures
Timeoutsconfigure via Client::builder()
Authentication refreshinject a token provider instead of a static token
Rate-limit handlinginspect 429 responses and Retry-After headers

When this pattern is especially useful

A typed API client is ideal when:

  • your application depends on a stable REST API
  • multiple modules need the same remote service
  • you want compile-time checks for request and response shapes
  • you need a clean boundary between business logic and transport

It is less useful for one-off scripts or highly dynamic endpoints where the schema changes frequently and typed models would add friction.

Conclusion

A Rust API client built with reqwest and serde gives you a strong foundation for reliable service integration. By wrapping the HTTP layer in a dedicated type, modeling JSON explicitly, and centralizing error handling, you get code that is easier to read, test, and evolve.

The same approach scales from a small internal tool to a larger application with many endpoints. Start with a minimal client, then add helpers for shared request logic, pagination, retries, and authentication as your needs grow.

Learn more with useful resources