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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| 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 | 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 | 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 | 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 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.
Related how-tos
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
-
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
Errorimplementation by hand, and switching between the two is not a breaking change. The derive produces plainstd::error::ErrorandDisplayimplementations, 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 -
The guideline is C-GOOD-ERR, and its text is a list of what an error without
SendorSynccannot do. It cannot be returned from a thread started withthread::spawn, cross threads inside anArc, or be packed into anio::Errorwithio::Error::new, whose bound isError + 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 -
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