# 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.

Source: https://voxgig.com/howto/model-api-errors-in-rust-with-thiserror

- Audience: api-consumer
- Level: intermediate
- Languages: rust
- Verified: 2026-10-21
- Published: 2026-10-17

## 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](https://docs.rs/wiremock/latest/wiremock/)
server was run.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [anyhow](https://docs.rs/anyhow/latest/anyhow/) | A binary that reports errors and never branches on them | One opaque type, so a library user cannot match a variant and has to downcast to learn what happened | You publish a crate whose callers branch on the failure |
| [reqwest error_for_status](https://docs.rs/reqwest/latest/reqwest/struct.Response.html#method.error_for_status) | A script where any non-2xx stops the run and the body is never read | The response is consumed to build the error, so the problem document is gone, and the status comes back as an `Option` | The API sends problem documents your callers need |
| [snafu](https://docs.rs/snafu/latest/snafu/) | The same underlying error occurs in several places and each needs its own context | A context selector type per variant and a `.context()` call at every site, so the enum grows with the call sites | One `From` per source type is enough and the `?` can stay bare |
| [thiserror](https://docs.rs/thiserror/latest/thiserror/) | A library with a public enum whose variants callers match on | A public type you must keep source-compatible, so every variant is a commitment and `#[non_exhaustive]` is the only escape | A binary that only logs the error |

anyhow and thiserror sit on opposite sides of the crate boundary.
[The anyhow documentation](https://docs.rs/anyhow/latest/anyhow/) 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.

```rust title="src/error.rs"
#[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](https://docs.rs/thiserror/latest/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](https://rust-lang.github.io/api-guidelines/interoperability.html#c-good-err)
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](https://www.rfc-editor.org/rfc/rfc9457.html) 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](https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html)
describes, so one `From` implementation per source type keeps every method body free of
`map_err`.

```rust title="src/error.rs"
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`](https://doc.rust-lang.org/std/net/struct.TcpStream.html#method.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.

```rust title="src/reqwest_transport.rs"
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`](https://docs.rs/reqwest/latest/reqwest/struct.Error.html) and `is_decode` map
directly. The `status()` branch is the trap this page is about: the reqwest method
[`error_for_status`](https://docs.rs/reqwest/latest/reqwest/struct.Response.html#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.

```rust title="src/client.rs"
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.

```bash
cargo run -q
```

```text output
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.

```bash
cargo test -- --test-threads=1
```

```text output
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](https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute)
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.

```bash
cargo build -q --manifest-path consumer/Cargo.toml --target-dir target
```

```text output
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](https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new)
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.

## Related how-tos

- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Design error responses an autonomous agent can act on](/howto/error-responses-an-agent-can-act-on)

## 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.

[^1]: [The thiserror documentation](https://docs.rs/thiserror/latest/thiserror/) 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.

[^2]: The guideline is [C-GOOD-ERR](https://rust-lang.github.io/api-guidelines/interoperability.html#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.

[^3]: The attribute shipped in [Rust 1.40.0](https://blog.rust-lang.org/2019/12/19/Rust-1.40.0/),
in December 2019. [The reference](https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute)
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.