How-to › Make calls that survive failure

How to model API errors in Rust with thiserror and Result#

Give a Rust API client one error enum with a variant per failure class, derived with thiserror and returned from every method, so callers match on what went wrong.

Audience
API consumer
Level
intermediate
Topic
Handle and design API errors
Languages
Rust
Verified

Your Rust client returns Box<dyn Error> from every method, so a caller who wants to retry a 503 and give up on a 404 has to match on message strings. The one who switched to reqwest::Error can tell a timeout from a refusal, but every 4xx arrives as error_for_status left it: a status with the problem body gone.

What you get

You will end up with a public ApiError enum whose four variants a caller can match and classify as retryable, proven by twelve tests against a loopback mock. This is for you if you maintain a Rust crate that calls an HTTP API.

Short answer

Define ApiError with thiserror as an enum with one variant per failure class: Status with the code and the problem document, Transport, Decode, and Timeout. Return Result<T, ApiError> from every method. Implement From for the transport’s error type so ? wraps it, mark the enum #[non_exhaustive], and read the status and the body yourself rather than calling error_for_status, which drops the body.

You will need

Rust 1.97 or a later stable toolchain with Cargo, and a client that talks HTTP through a layer you can wrap, such as reqwest. Verified 2026-10-21 against Rust 1.97.0, thiserror 2.0.21, serde_json 1.0.154, and reqwest 0.13.5. The sample runs a fake metering API on 127.0.0.1 behind a std::net transport, so the checks compile thiserror and serde_json alone. The reqwest conversion sits behind a Cargo feature and was compiled once with the feature on. Nothing on this page leaves the machine, and no wiremock server was run.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
anyhowA binary that reports errors and never branches on themOne opaque type, so a library user cannot match a variant and has to downcast to learn what happenedYou publish a crate whose callers branch on the failure
reqwest error_for_statusA script where any non-2xx stops the run and the body is never readThe response is consumed to build the error, so the problem document is gone, and the status comes back as an OptionThe API sends problem documents your callers need
snafuThe same underlying error occurs in several places and each needs its own contextA context selector type per variant and a .context() call at every site, so the enum grows with the call sitesOne From per source type is enough and the ? can stay bare
thiserrorA library with a public enum whose variants callers match onA public type you must keep source-compatible, so every variant is a commitment and #[non_exhaustive] is the only escapeA binary that only logs the error

anyhow and thiserror sit on opposite sides of the crate boundary. The anyhow documentation describes a trait-object error type for applications, and the cost of using it in a library is that your users downcast to learn what happened. snafu keeps an enum and adds a context selector per variant, which pays off when one source error occurs in several places and costs a .context() at every call site. error_for_status is the cheapest of the four and loses the body.

Define one variant per failure class

Each variant exists because a caller does something different with it. A Status is the API’s own answer and carries its problem document, and a Decode failure means the API changed shape under you. A Transport failure or a Timeout means no answer arrived. Neither says whether the API processed the request, because a connection can drop after the request went out.

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ApiError {
    /// A 4xx or 5xx response. `problem` is `Some` when the body was a problem document.
    #[error("{status} {}", describe(.problem))]
    Status { status: u16, problem: Option<Problem> },

    /// No response arrived: a refused connection, DNS, TLS, a reset.
    #[error("transport failure: {0}")]
    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),

    /// A 2xx whose body is not what the method promised.
    #[error("could not decode the response body: {0}")]
    Decode(#[source] Box<dyn std::error::Error + Send + Sync>),

    /// The deadline passed before a response arrived.
    #[error("request timed out")]
    Timeout,
}

thiserror writes the Display and Error implementations from the attributes.1 The #[error("...")] text is the Display output, with fields interpolated by name and an extra argument allowed for a helper such as describe, which turns the problem document into one line. #[source] marks the field that Error::source() returns, so a logger walking the chain reaches the io::Error or the serde_json::Error underneath.

The two boxed sources are Box<dyn Error + Send + Sync> rather than the concrete types, and the choice pays twice. The enum stays Send and Sync, which the Rust API guidelines ask of every public error type,2 and the transport library’s error type stays out of your public API. #[non_exhaustive] is the other commitment: it lets a fifth variant arrive in a minor release, at the price of a wildcard arm in every caller’s match.3 The Problem struct holds the fields of an RFC 9457 document, with its errors list of pointers for a 422.

Let the question mark do the wrapping

The ? operator calls From::from on the error it propagates, as the Rust book describes, so one From implementation per source type keeps every method body free of map_err.

impl From<serde_json::Error> for ApiError {
    fn from(e: serde_json::Error) -> Self {
        ApiError::Decode(Box::new(e))
    }
}

impl From<io::Error> for ApiError {
    fn from(e: io::Error) -> Self {
        match e.kind() {
            io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => ApiError::Timeout,
            _ => ApiError::Transport(Box::new(e)),
        }
    }
}

The io::Error conversion reads the kind rather than the message, and it maps two kinds to Timeout because set_read_timeout documents that Unix reports a timed-out read as WouldBlock and Windows as TimedOut. A reqwest client needs the same conversion for reqwest::Error, compiled here behind a feature.

impl From<reqwest::Error> for ApiError {
    fn from(e: reqwest::Error) -> Self {
        if e.is_timeout() {
            return ApiError::Timeout;
        }
        if e.is_decode() {
            return ApiError::Decode(Box::new(e));
        }
        // Only `error_for_status()` produces an error with a status, and by then the body
        // has been dropped with the response. Read the status yourself first, as `Client`
        // does, so the problem document reaches the caller.
        if let Some(status) = e.status() {
            return ApiError::Status { status: status.as_u16(), problem: None };
        }
        ApiError::Transport(Box::new(e))
    }
}

is_timeout and is_decode map directly. The status() branch is the trap this page is about: the reqwest method error_for_status consumes the response to build the error, so by the time From runs the problem document is gone and the variant can only say problem: None. The client therefore reads the status itself and parses the body before deciding that the answer is an error.

let raw = self.transport.get(&format!("/v1/meters/{}", segment(id)))?;

// Read the body before deciding the status is an error: a problem document is the
// most useful thing a 4xx carries, and it is gone once the response is consumed.
if !(200..300).contains(&raw.status) {
    return Err(ApiError::Status { status: raw.status, problem: Problem::parse(&raw.body) });
}

// The `?` on serde_json::from_slice becomes `Decode`; so does a missing field.
let v: Value = serde_json::from_slice(&raw.body)?;

The id goes through segment, which percent-encodes it, so ../admin/keys stays one path segment instead of resolving to another endpoint.

Run one request per variant against the mock

The mock is a thread on 127.0.0.1 with one canned answer per path, including a path that never answers. cargo run sends one request to each and prints the variant a caller would match, with the is_retryable() classification beside it.

cargo run -q
meter id     result                                                           retryable
mtr_42       Ok: Hall with 3 readings
mtr_404      Status 404: Not Found                                            false
invalid      Status 422: Unprocessable Content with 2 field errors            false
mtr_500      Status 500: Internal Server Error                                true
plain        Status 503, no problem document                                  true
garbage      Decode: expected value at line 1 column 1                        false
renamed      Decode: missing string field `name`                              false
slow         Timeout                                                          true
no server    Transport: ConnectionRefused                                     true

Two lines deserve a second look. The 503 with a plain-text body is still a Status, with problem: None, because Problem::parse returns None for anything that is not a JSON object with a string type, title, or detail. The HTML 502 a load balancer sends lands the same way. No single member is required, because RFC 9457 makes all of them optional.

The renamed line is a 200 whose body lacks a field the type promises, so it is a Decode failure with a message naming the field. An API that renames name to label fails loudly instead of returning an empty string.

Check it worked

Run the integration tests on one thread, so that their order is stable. They live in tests/, which Cargo compiles as a separate crate, so every match in them needs the same wildcard arm a downstream user needs.

cargo test -- --test-threads=1
running 12 tests
test a_2xx_decodes_into_the_type ... ok
test a_2xx_with_the_wrong_body_is_decode ... ok
test a_404_is_status_with_the_problem_document ... ok
test a_422_carries_its_field_errors ... ok
test a_5xx_is_status_and_retryable_with_or_without_a_document ... ok
test a_caller_can_classify_without_naming_every_variant ... ok
test a_problem_document_needs_no_title ... ok
test a_refused_connection_is_transport_with_the_io_error_as_source ... ok
test a_silent_server_is_timeout ... ok
test an_id_stays_one_path_segment ... ok
test the_error_is_send_sync_and_static ... ok
test the_mock_reads_a_request_sent_in_pieces ... ok

The result line that follows reads 12 passed. Ten cover a request per variant, the problem document’s fields on a 404 and a 422, a document with no title, and a 200 missing a field. They also check that Error::source() reaches the io::Error behind a refused connection, and that ApiError is Send + Sync + 'static at compile time. The last two check that an id stays one path segment and that the mock reads a request whose first line arrives in pieces.

When it goes wrong

A caller’s build breaks the day you add a variant. The Rust reference says #[non_exhaustive] has no effect within the defining crate, so your own code can match exhaustively while a downstream crate cannot. The consumer directory is such a crate: it matches all four variants and nothing else.

cargo build -q --manifest-path consumer/Cargo.toml --target-dir target
error[E0004]: non-exhaustive patterns: `&_` not covered
  --> src/main.rs:6:11
   |
 6 |     match e {
   |           ^ pattern `&_` not covered

The rest of the message names the enum, notes that it is marked non-exhaustive, and suggests a wildcard arm. That is the compile error you want your users to meet on day one rather than on the day you add RateLimited. Without the attribute, the Cargo semver reference classes a new variant as a major change.

Every 4xx arrives as Status { problem: None }. The client called error_for_status before reading the body, and the response was consumed to build the error. Read the status first and the body second, as meter does, and keep the status() branch of the conversion as a safety net rather than the main road.

A refused connection shows up as Timeout, or a timeout as Transport. The conversion matched on the error message, or on one io::ErrorKind, when a timed-out read is WouldBlock on Unix and TimedOut on Windows. Match on the kind, on both kinds, and let a test pin which variant each failure becomes.

When not to do this

Do not return anyhow::Error from a library. It is the right type for the binary that calls you, and in your crate’s public signature it turns every variant into a downcast_ref your users have to know to try. Convert at the boundary instead, in the binary, where the ? of anyhow accepts your ApiError without a line of code.

Do not put the transport’s error type in a public variant. Transport(reqwest::Error) makes reqwest part of your API, so their next major version is your next major version. Box it as dyn Error + Send + Sync, and let a caller who needs the detail downcast the source.

Do not add a variant per status code. NotFound, Unauthorized, and Conflict read well and leave a caller matching on Status { status: 404, .. } anyway for the codes you did not name. One Status variant with the code and the document covers them all, and a helper such as is_retryable carries the classification.

Do not make the enum opaque unless callers never branch. The thiserror documentation shows an opaque struct wrapping a private enum. That is right for a crate whose errors are only ever logged, and wrong for a client whose callers need to tell a 404 from a 503.

Last verified

Verified 2026-10-21 against Rust 1.97.0, thiserror 2.0.21, serde_json 1.0.154, and reqwest 0.13.5. Every output block is what the command preceding it printed. The mock API and the std::net transport are the page’s own code, so no request left the machine. The reqwest conversion was compiled with cargo check --features reqwest and is not exercised by the tests; no wiremock server was run.

Footnotes

  1. The thiserror documentation says the crate deliberately does not appear in your public API. You get the same thing as if you had written the Error implementation by hand, and switching between the two is not a breaking change. The derive produces plain std::error::Error and Display implementations, so a user of your crate never meets a thiserror type in your signatures, which is more than most dependencies can say. ↩︎ Back to text

  2. The guideline is C-GOOD-ERR, and its text is a list of what an error without Send or Sync cannot do. It cannot be returned from a thread started with thread::spawn, cross threads inside an Arc, or be packed into an io::Error with io::Error::new, whose bound is Error + Send + Sync. The guideline asks for the two auto traits by listing the doors they open, which is the persuasive form of a rule. ↩︎ Back to text

  3. The attribute shipped in Rust 1.40.0, in December 2019. The reference gives it one sentence of effect and one of exemption: a type or variant may gain fields or variants later, and within the defining crate the attribute does nothing. The exemption is why a crate’s own tests cannot catch the mistake this page’s consumer makes. The defining crate is the one place the compiler takes the author at their word. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.