Error-Handling Rules
Libraries expose typed, actionable errors; applications aggregate context and report to users and logs. Panics are for bugs, not expected failures.
Recipe
// library
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("not found: {0}")]
NotFound(UserId),
}
// application
fn main() -> anyhow::Result<()> {
run().context("app startup")?;
Ok(())
}When to reach for this: Any public API boundary and binary main.
Working Example
| Layer | Type | Context |
|---|---|---|
| Domain | thiserror enum | precise variants |
| Adapter | map_err to domain | translate sqlx/reqwest |
| Handler | IntoResponse | status + problem JSON |
| main | anyhow::Result | human-readable chain |
pub fn load_config() -> Result<Config, ConfigError> { /* ... */ }
async fn handler() -> Result<Json<User>, ApiError> {
let user = repo.get(id).await?;
Ok(Json(user))
}What this demonstrates:
?propagates in same error type orFromimpl- Libraries never
unwrapuser input paths - Apps add
.context("while X")at boundaries source()chain preserved for logs
Deep Dive
- Use
#[non_exhaustive]on public error enums for semver flexibility anyhowonly in binaries/integration tests, not published lib public APIError::sourcefor chaining; avoid string-only errors in libraries
Gotchas
- String errors in library - untyped matching. Fix: enum variants.
- anyhow in lib public fn - erases types for callers. Fix: concrete
Result<T, Error>. - panic for validation - bad UX. Fix:
400with message. - map_err lose source - blind
.map_err(|e| e.to_string()). Fix:#[from]or transparent variants. - ignore errors with let _ - silent failures. Fix: log or propagate.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
eyre | app error reports | Published library API |
| Panic | invariant violation | User typo in form |
Option | truly optional | operation can fail |
FAQs
one error enum per crate?
Common pattern; submodules use #[from] nested errors.
serde errors?
Wrap serde_json::Error in domain variant with context.
axum error response?
Implement IntoResponse on ApiError mapping variants to status.
backtrace?
RUST_BACKTRACE=1 in staging; anyhow optional backtrace feature.
retry which errors?
Transient network/timeout only; not validation failures.
error mod visibility?
pub use Error from crate root; hide internal variants if needed with opaque type.
test errors?
Match on ErrorKind or variant helpers, not Display strings.
no_std errors?
core::fmt::Display manual impl; no anyhow.
grpc status?
Map domain errors to tonic Status in adapter layer.
document errors?
Rustdoc # Errors on every fallible pub fn.
Related
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.