| name | rust-error-handling |
| description | Guides expert-level Rust error handling: thiserror vs anyhow decision tree, error propagation with ? operator, custom error types, Result combinators, and error conversion patterns.
Use when the user asks about Rust error handling, thiserror, anyhow, Result type, ? operator, custom errors, error propagation.
Do NOT use when the user asks about Rust ownership (use `rust-ownership-patterns`), Rust project setup (use `rust-project-setup`), Rust testing (use `rust-testing-patterns`).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"rust best-practices debugging","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
Rust Error Handling
When to Use
Use this skill when:
- The user asks how to choose between
thiserror and anyhow for a Rust project
- The user is designing a custom error type for a library crate and needs to understand
std::error::Error trait implementation
- The user has a function returning
Result<T, E> and wants to propagate or transform errors using ?, .map_err(), .and_then(), or combinators
- The user needs to add context to errors (e.g., "failed to parse config at line 42") without losing the original cause
- The user is hitting compiler errors like "the
? operator can only be used in a function that returns Result or Option" or "the trait From<SomeError> is not implemented"
- The user wants to define an error hierarchy for a multi-module crate with variants for different failure categories
- The user is writing a CLI tool or binary application and wants ergonomic top-level error reporting
- The user needs to convert between error types at FFI, async, or service boundaries
- The user is working with
Box<dyn Error> and wants to migrate to a more structured approach
Do NOT use this skill when:
- The user asks about Rust ownership, lifetimes, or borrow checker issues -- use
rust-ownership-patterns
- The user is setting up a new Rust project structure, workspace layout, or Cargo.toml dependencies -- use
rust-project-setup
- The user asks about writing unit tests, integration tests, or property-based tests in Rust -- use
rust-testing-patterns
- The user asks about async Rust runtime selection or
tokio vs async-std architecture -- use rust-async-patterns
- The user is asking about logging frameworks (
tracing, log) in isolation without an error handling question -- use rust-observability
- The user asks about panics as a design mechanism for invariant violations -- that is a separate topic from recoverable error handling
Process
1. Identify the Crate Type and Error Consumer
The single most important question in Rust error handling is: who reads this error?
- Library crate (
lib.rs root): Your users are other Rust programmers. They need to match on specific variants. Use thiserror to define strongly-typed, matchable error enums. Never use anyhow::Error in a library's public API -- it erases type information that downstream code cannot recover.
- Binary / application crate (
main.rs): Your users are humans at a terminal or log aggregator. Use anyhow::Error at the top level for ergonomic ? chaining and rich context strings. The exact type rarely matters once you reach main.
- Dual-use crate (library with a binary target): Keep the library's public API using typed errors (
thiserror). In the src/bin/ or main.rs layer, convert to anyhow::Error at the outermost boundary using ? or .map_err(|e| anyhow::anyhow!(e)).
- Internal modules within a large crate: You do NOT need a distinct error type per module. Group errors at the public API surface, not at every internal helper. Over-granular error types cause churn.
- Proc-macro crate: Use
syn::Error and proc_macro2::Span -- these integrate with the compiler's diagnostic infrastructure and are not replaceable with thiserror or anyhow.
2. Design the Error Enum Structure
Once you know the crate type, design the error enum with these concrete rules:
- One error enum per public API surface, not per file. A
DatabaseError covers all DB operations; you do not need QueryError, ConnectionError, TransactionError as separate top-level types unless they appear in separate public modules.
- Use
thiserror::Error derive macro. It implements std::error::Error, Display, and optionally From<SourceError> for you. Avoid writing these by hand.
- Each variant maps to a distinct failure category that callers might handle differently. If two variants would always trigger the same recovery logic, merge them.
- Embed structured context in variant fields, not in format strings. Prefer
IoError { path: PathBuf, source: std::io::Error } over IoError(String). This lets callers inspect the path without parsing strings.
- Mark the source error with
#[source] (or the shorthand #[from] when you also want automatic From impl). #[source] populates the Error::source() chain; #[from] additionally generates impl From<SourceError> for YourError.
- Do NOT use
#[from] indiscriminately. If two variants wrap the same source type (e.g., two variants both wrapping std::io::Error), #[from] cannot distinguish them -- you must use .map_err() explicitly at call sites.
- Avoid
Box<dyn Error + Send + Sync> in library enums unless you genuinely cannot know the source type at compile time (e.g., plugin systems). It loses type information and forces heap allocation.
- Add
#[non_exhaustive] to library error enums when you anticipate adding variants in future minor versions. Without it, adding a variant is a breaking change.
3. Apply the ? Operator and From Trait Correctly
The ? operator desugars to: evaluate the Result, on Err(e) call return Err(From::from(e)). Understanding this prevents 80% of error propagation confusion.
? works automatically when impl From<SourceError> for TargetError exists. This is what #[from] generates. If you get a "the trait bound is not satisfied" error, you are missing a From impl or the function's return type is wrong.
- Use
.map_err(|e| TargetError::Variant { field: ctx, source: e })? when you need to attach context that #[from] cannot supply (e.g., the file path, the key name, the line number).
- Never use
.unwrap() or .expect() in library code paths that can receive user input. Reserve .expect() for cases where failure indicates a programming error (invariant violation), not an environmental failure. Document the invariant in the .expect() message.
- Chain
? through async functions freely -- ? works identically in async fn returning Result. There is no special async syntax needed.
- In
Option contexts, use .ok_or_else(|| MyError::MissingField { name: "host" })? to convert Option<T> to Result<T, MyError> before applying ?.
- Avoid deeply nested
match on Result. More than two levels of nesting signals you should extract a helper function or use combinators.
4. Add Error Context Without Losing the Cause
Naked errors without context ("No such file or directory") are useless in production. Add context at every layer crossing.
- In application code with
anyhow: use .context("loading database config") or .with_context(|| format!("reading file {}", path.display())). This wraps the error with a message while preserving the original via the source() chain.
- In library code with
thiserror: add context as variant fields (path: PathBuf, key: String, line: usize). Context belongs in the type, not in a string attached later.
- Never double-wrap context. If a function already returns
MyError::Io { path, source }, do not also add .context("io error") at the call site -- the path is already in the error.
- Correlation IDs and request IDs belong in the logging layer (via
tracing spans), not inside error types. Error types carry structural cause; tracing carries execution context.
- When wrapping a third-party error, capture only what you need. If
reqwest::Error contains the URL, do not duplicate it in your wrapper field -- call .url() on the source in your Display impl instead.
5. Select and Apply Result Combinators Appropriately
Result combinators compose transformations without explicit match. Use them for clarity, not for cleverness.
.map(|v| transform(v)) -- transform the Ok value, leave Err untouched. Use when the transformation is infallible.
.map_err(|e| convert(e)) -- transform the Err value, leave Ok untouched. The most common combinator at type boundaries.
.and_then(|v| fallible_transform(v)) -- apply a function that itself returns Result. Equivalent to ? inside a closure. Use for chaining dependent fallible operations.
.or_else(|e| fallback(e)) -- attempt a recovery; if the recovery also fails, return the new error. Use for retry logic or fallback sources.
.unwrap_or_else(|e| default_value(e)) -- consume the error and produce a default. Only appropriate when an error is genuinely expected and non-fatal.
.flatten() -- converts Result<Result<T, E>, E> to Result<T, E>. Useful after .map() that returns another Result.
- Avoid chaining more than 3-4 combinators in a single expression. Beyond that, extract a named function. The cognitive overhead exceeds the terseness benefit.
Iterator of Results: use .collect::<Result<Vec<_>, _>>() to fail fast on the first error, or .filter_map(|r| r.ok()) to skip errors silently (document when you do the latter).
6. Handle Errors at Boundaries
Errors must be translated, logged, or surfaced at each architectural layer crossing.
- HTTP API boundary: Map error variants to HTTP status codes in a centralized handler, not scattered through route handlers. A
match on AppError variants produces StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND, StatusCode::INTERNAL_SERVER_ERROR. Never expose internal error messages in 5xx responses -- log them with a request ID, return the ID to the client.
- CLI boundary: Implement
main() -> Result<(), anyhow::Error> (or eyre::Report for richer formatting). anyhow will print the error chain automatically on Err. For prettier output, use miette with #[diagnostic] annotations.
- FFI boundary: Rust panics must not cross FFI. Wrap any
Result-returning Rust function called from C in std::panic::catch_unwind. Convert Err to a C-compatible integer code or out-parameter error string. Never let ? propagate across extern "C" fn.
- Thread/task boundary:
tokio::task::JoinHandle returns Result<T, JoinError>. A JoinError can be a panic payload. Check .is_panic() and handle panics explicitly. Do not let them silently disappear.
- Deserialization boundary (serde):
serde_json::from_str returns a generic serde_json::Error. Wrap it immediately with .map_err(|e| MyError::DeserializationFailed { field: "config", source: e }) rather than letting the raw serde error bubble up to callers.
7. Write Ergonomic Error Display Implementations
Display for errors is the message a human reads. Write it with care.
- Use lowercase for error messages unless a proper noun requires capitalization. This follows Rust ecosystem convention (matching
std errors) and allows embedding in sentences: format!("failed to connect: {}", e).
- Do not end error messages with a period. They are fragments, not sentences.
- Do not include "Error" in the message text. The caller's context supplies that word. Write "connection refused" not "Connection error: connection refused".
- In
thiserror, use #[error("...")] attribute with {field_name} interpolation. Use {source} to embed the source error's display in the message when useful.
- Make messages specific. "failed to read file
/etc/app/config.toml" is useful. "IO error" is not.
- For
anyhow context strings, write them as lowercase verb phrases: .context("parsing configuration"), .context("connecting to database").
8. Validate and Test Error Paths
Error paths are code paths. Test them like production code.
- Test that the correct error variant is returned for each failure condition using
assert!(matches!(result, Err(MyError::Variant { .. }))) or the assert_matches! macro (stabilized in Rust 1.82).
- Test the
Display output of your error types to ensure messages are human-readable and do not regress. A simple assert_eq!(format!("{}", err), "expected message") catches accidental changes.
- Test the
source() chain when it matters: assert!(err.source().is_some()) confirms the cause is preserved.
- Use
should_panic tests sparingly -- they test panic messages, not error types, and are fragile.
- For libraries, test that error types implement
Send + Sync using a compile-time assertion: fn assert_send_sync<T: Send + Sync>() {} called with your error type in a test. Many async runtimes require Send errors.
- Property-based testing with
proptest or quickcheck is valuable for parsing/deserialization code -- generate malformed inputs and verify you always get Err, never a panic.
Output Format
When responding to a Rust error handling question, structure the response as follows:
## Error Handling Strategy for [Context]
### Crate Type Assessment
[Library / Application / Dual-use] -- [one sentence rationale]
### Recommended Dependencies
[thiserror and/or anyhow, with version and Cargo.toml snippet]
### Decision Matrix
| Scenario | Library Crate | Application Crate | Recommendation |
|-----------------------------------|-----------------|-------------------|------------------------------|
| Public API error types | thiserror enum | anyhow::Error | thiserror for libraries |
| Internal helper functions | ? propagation | ? propagation | propagate without conversion |
| Top-level main / request handler | N/A | anyhow / miette | anyhow::Result in main |
| Wrapping third-party errors | #[from] or map_err | .context() | depends on type stability |
| Multiple source error types | enum variants | .context() chain | enum for libraries |
### Error Type Definition
[Complete, compilable Rust code for the error enum]
### Propagation Pattern
[Complete code showing ? usage, map_err, and context attachment]
### Boundary Handling
[Code showing how errors surface at API, CLI, or FFI boundary]
### Common Pitfalls to Avoid
[Bulleted list of 3-5 specific mistakes relevant to the user's situation]
Rules
-
Never recommend anyhow::Error in a library's public API. It erases type information. Downstream callers cannot match on it. If a library author asks what to use, the answer is always thiserror or a hand-rolled std::error::Error implementation.
-
Never use #[from] for two variants that wrap the same source type. The From trait cannot have two implementations for the same source type in the same impl block. If this situation arises, write explicit .map_err() conversions and use #[source] (not #[from]) on the variant fields.
-
Never let a Rust panic cross an FFI boundary. Catching a panic via catch_unwind and converting to an error code is mandatory for extern "C" functions. Failure to do this causes undefined behavior in the calling process.
-
Never use Box<dyn Error> as a function return type unless the codebase is using it for a plugin/dynamic dispatch system. In almost all other cases it is a sign of premature generalization or a copy from a pre-thiserror tutorial. Replace it with a concrete error enum.
-
Always implement Send + Sync for errors used in async contexts. tokio and other runtimes require errors to be Send. thiserror-derived types are Send + Sync if all their fields are. Verify this. Box<dyn Error> is NOT Send + Sync by default -- use Box<dyn Error + Send + Sync> if you must use it.
-
Never add .context() or .map_err() at every single call site in application code. Context should be added at meaningful layer crossings (loading config, connecting to DB, processing a request) -- not on every individual ?. Over-annotated error chains become noise.
-
Never derive Copy for error types. Errors often contain String, PathBuf, or heap-allocated fields. Error types should be Clone when needed but are rarely Copy. Attempting this will cause compiler errors.
Edge Cases
The #[from] Collision Problem
When two variants in the same enum both need to wrap std::io::Error, #[from] cannot be used because Rust does not allow two impl From<io::Error> for MyError blocks. The solution: use #[source] on both variants (which preserves the error chain) and write explicit .map_err() at each call site to specify which variant to produce.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file `{path}`")]
ReadFailed { path: PathBuf, #[source] source: std::io::Error },
#[error("failed to write config file `{path}`")]
WriteFailed { path: PathBuf, #[source] source: std::io::Error },
}
fs::read_to_string(&path).map_err(|e| ConfigError::ReadFailed { path: path.clone(), source: e })?;
Async Tasks and JoinError
tokio::task::spawn returns JoinHandle<Result<T, E>>. Awaiting it gives Result<Result<T, E>, JoinError>. Callers must handle both layers. Panics inside tasks become JoinError with is_panic() == true. The standard pattern is:
let result = task_handle.await
.map_err(|e| if e.is_panic() {
AppError::TaskPanicked
} else {
AppError::TaskCancelled
})??;
Never flatten this with .unwrap() -- a panicking task will propagate the panic to the caller's thread.
Error Types in Trait Objects (dyn Trait)
When defining a trait that returns errors, the error type must be part of the trait signature or use Box<dyn Error + Send + Sync>. Using an associated type (type Error: std::error::Error) is the ergonomic approach for libraries. Using Box<dyn Error + Send + Sync> trades type safety for object safety. If a trait needs to be object-safe AND return meaningful errors, the associated type approach breaks object safety -- use Box<dyn Error + Send + Sync> and document the trade-off explicitly.
Error Conversion in Large Dependency Trees
When a crate depends on many sub-crates, each with their own error types, the top-level error enum can explode in variant count. Signs of this problem: your AppError enum has 40+ variants, each wrapping a single foreign error type. The solution is error aggregation layers: create DatabaseError, NetworkError, StorageError mid-level enums that each consolidate a domain's errors, then have AppError wrap these mid-level enums. This limits AppError to 6-10 variants representing business domains, not implementation details.
The anyhow / thiserror Mix in a Workspace
In a Cargo workspace with multiple crates, the pattern is: all lib crates use thiserror; the application binary crate uses anyhow. The binary's main.rs imports library errors as use my_lib::LibError and they convert to anyhow::Error transparently via ? because anyhow implements From<E: std::error::Error>. This is seamless -- you do not need to write any conversion glue. The only mistake is accidentally publishing an internal anyhow dependency in a library's public API.
serde Deserialization Errors with Custom Context
serde_json::Error exposes line/column information via .line() and .column() methods. When wrapping serde errors in your own type, preserve this structured data as fields rather than stringifying it:
#[derive(Debug, thiserror::Error)]
#[error("invalid JSON in {file} at line {line}, column {column}")]
pub struct ParseError {
pub file: PathBuf,
pub line: usize,
pub column: usize,
#[source]
pub source: serde_json::Error,
}
This allows callers to programmatically access position data, which is essential for editors and language servers.
miette for User-Facing CLI Errors
When the end user is a developer (e.g., a CLI tool, build system, or compiler plugin), miette provides diagnostics with source code snippets, labels, and help text -- similar to Rust compiler errors. Use miette::Diagnostic derive alongside thiserror::Error. This is most valuable when your tool processes source files or configuration formats where pointing to the exact problematic line/column is critical. miette is NOT needed for server applications where errors go to log aggregators.
Recoverable vs. Fatal Error Distinction
Some errors indicate unrecoverable state corruption (e.g., a corrupted database page, an impossible enum variant from deserialization). These should NOT be modeled as error variants to be matched and recovered. Use panic! with a clear message, or define a separate Fatal type that your top-level handler converts to process exit with a diagnostic. Mixing recoverable and fatal errors in the same enum forces callers to handle cases that can never succeed.
Example
Input: "I'm building a Rust library crate that parses TOML configuration files and loads them from disk. It needs to work in an async context with tokio. What error handling approach should I use?"
Output:
Error Handling Strategy for a TOML Configuration Library
Crate Type Assessment
Library crate -- error types must be strongly typed and matchable by downstream application code. anyhow is excluded from the public API.
Recommended Dependencies
[dependencies]
thiserror = "1"
tokio = { version = "1", features = ["fs"] }
toml = "0.8"
serde = { version = "1", features = ["derive"] }
Decision Matrix
| Scenario | Approach | Rationale |
|---|
Public ConfigError type | thiserror enum | Callers must match NotFound vs ParseError vs IoError |
Wrapping std::io::Error | #[source] + PathBuf field | Two IO variants prevent #[from] use |
Wrapping toml::de::Error | #[from] if single variant | Only one TOML parse error variant -- #[from] is safe |
| Async tokio FS operations | Same ? propagation | No special async error handling needed |
| Application using this library | .context() with anyhow | App layer adds runtime context, library type is preserved |
Error Type Definition
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("config file not found at `{path}`")]
NotFound { path: PathBuf },
#[error("failed to read config file `{path}`")]
ReadFailed {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse config file `{path}`")]
ParseFailed {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("config file `{path}` has invalid structure: {message}")]
InvalidStructure { path: PathBuf, message: String },
}
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ConfigError>();
};
Propagation Pattern
use serde::Deserialize;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Deserialize)]
pub struct AppConfig {
pub database_url: String,
pub port: u16,
pub max_connections: Option<u32>,
}
pub async fn load_config(path: &Path) -> Result<AppConfig, ConfigError> {
let contents = match fs::read_to_string(path).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ConfigError::NotFound { path: path.to_owned() });
}
Err(e) => {
return Err(ConfigError::ReadFailed {
path: path.to_owned(),
source: e,
});
}
};
let config: AppConfig = toml::from_str(&contents).(|e| ConfigError::ParseFailed {
path: path.(),
source: e,
})?;
config.port == {
(ConfigError::InvalidStructure {
path: path.(),
message: .(),
});
}
(config)
}
Boundary Handling in Application Code
The application binary uses anyhow for ergonomics. The library's typed errors convert automatically:
use anyhow::{Context, Result};
use std::path::PathBuf;
use my_config_lib::{load_config, ConfigError};
#[tokio::main]
async fn main() -> Result<()> {
let config_path = PathBuf::from("/etc/myapp/config.toml");
let config = load_config(&config_path).await
.context("startup failed: could not load application config")?;
let config = match load_config(&config_path).await {
Ok(c) => c,
Err(ConfigError::NotFound { .. }) => {
eprintln!("No config file found, using defaults");
AppConfig::default()
}
Err(e) => return Err(anyhow::anyhow!(e).context("failed to load config")),
};
println!("Server starting on port {}", config.port);
Ok(())
}
Testing Error Paths
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tokio::fs;
#[tokio::test]
async fn test_missing_file_returns_not_found() {
let result = load_config(Path::new("/nonexistent/path/config.toml")).await;
assert!(
matches!(result, Err(ConfigError::NotFound { .. })),
"expected NotFound, got: {:?}",
result
);
}
#[tokio::test]
async fn test_invalid_toml_returns_parse_failed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, b"not valid toml ][[[").await.unwrap();
let result = load_config(&path).await;
assert!(matches!(result, Err(ConfigError::ParseFailed { .. })));
}
#[tokio::test]
async fn test_zero_port_returns_invalid_structure() {
= tempfile::().();
= dir.().();
fs::(&path, )..();
= (&path).;
(matches!(result, (ConfigError::InvalidStructure { .. })));
}
() {
= ConfigError::NotFound {
path: PathBuf::(),
};
(
(, err),
);
}
}
Common Pitfalls to Avoid
- Do not add
anyhow as a dependency of this library. It belongs in the binary. Adding it to a library's [dependencies] is a code smell that will propagate anyhow::Error into your public API.
- Do not use
#[from] source: std::io::Error when you have two variants both wrapping io::Error (NotFound and ReadFailed). The compiler will reject duplicate From impls. Use explicit map_err at call sites as shown above.
- Do not forget
#[non_exhaustive] before the first 0.1.0 publish. Adding it after first release is a breaking change in the opposite direction -- it would break existing exhaustive matches in downstream code.
- Do not embed
source.to_string() in variant fields. Keep the source error as a typed #[source] field. String conversion throws away the entire error chain below it and makes the source untraversable via Error::source().
- Do not panic on
toml::from_str errors even during development. The habit of using unwrap() in library parsing code is how panics slip into production libraries used by others.