How applications should propagate, enrich, inspect and finally report errors, with anyhow 1.x and std.
Covers main, ExitCode, custom Termination, backtraces and logging with tracing.
Contents
- 1. The API surface
- 2. Adding context well
- 3. bail!, ensure!, anyhow!
- 4. Downcasting and inspecting the chain
- 5. Report formats
- 6. Backtraces
- 7. main, ExitCode and Termination
- 8. Logging errors once with tracing
- 9. Interop: Box dyn Error, String errors, closures
- 10. Pitfalls
1. The API surface
| Item | Signature / behaviour |
|---|---|
anyhow::Error | boxed error + context chain + optional backtrace; one pointer wide |
anyhow::Result<T, E = anyhow::Error> | alias for core::result::Result<T, E> |
Context::context(self, ctx) | on Result<T, E> (E: std error or anyhow::Error) and Option<T>; wraps the error with ctx (Display + Send + Sync + 'static) |
Context::with_context(self, || ctx) | same, but the closure runs only on the error path |
anyhow!(..) / format_err!(..) | build an anyhow::Error from a format string or from an error value |
bail!(..) | return Err(anyhow!(..)) |
ensure!(cond, ..) | if !cond { bail!(..) } |
Error::msg(m) | error from any Display + Debug + Send + Sync + 'static (e.g. a String) |
Error::new(e) | from a std error (same as e.into()) |
err.context(ctx) | add a layer to an existing anyhow::Error |
err.downcast_ref::<T>(), downcast_mut, downcast, is::<T>() | typed access to the original error or any context value |
err.chain() | iterator over &(dyn Error + 'static): outermost first |
err.root_cause() | the innermost error |
err.backtrace() | &std::backtrace::Backtrace (Rust 1.65+) |
anyhow::Ok(v) | Ok::<_, anyhow::Error>(v); helps inference in closures/async blocks |
2. Adding context well
Context answers "what were we doing?" at each hop. The source answers "what went wrong?".
use anyhow::{Context, Result};
use std::path::Path;
fn import(dir: &Path) -> Result<usize> {
let mut count = 0;
for entry in std::fs::read_dir(dir).with_context(|| format!("listing {}", dir.display()))? {
let path = entry.context("reading directory entry")?.path();
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let rows: Vec<Row> = serde_json::from_str(&text)
.with_context(|| format!("parsing {} as JSON rows", path.display()))?;
count += store_rows(&rows).with_context(|| format!("storing rows from {}", path.display()))?;
}
Ok(count)
}Guidelines:
- Every I/O, parse, network or subprocess
?in application code gets context. A bareNo such file or directory (os error 2)does not say which file. The audit flags these as ERR012. - Name the input (path, URL, id, line number), not the function:
"reading users.csv"beats"read_users failed". contextfor literals,with_contextfor anything that allocates (format!), so the success path stays free.- Don't repeat the cause in the context:
"reading config", not"reading config: {err}". - Context on
Optionturns "missing" into an error with a message:let home = std::env::var_os("HOME").context("HOME is not set")?; - Context on library errors keeps them typed: after
store.get(id).context("loading user")?,err.downcast_ref::<StoreError>()still works.
3. bail!, ensure!, anyhow!
use anyhow::{anyhow, bail, ensure, Result};
fn parse_range(s: &str) -> Result<(u32, u32)> {
let (a, b) = s.split_once("..").ok_or_else(|| anyhow!("expected START..END, got {s:?}"))?;
let (a, b): (u32, u32) = (a.parse()?, b.parse()?);
ensure!(a <= b, "range start {a} is after end {b}");
if b - a > 10_000 {
bail!("range {s:?} is too large ({} items, max 10000)", b - a);
}
Ok((a, b))
}anyhow!(err_value)with a single expression that is an error keeps it as the source (it is not stringified); with a format string it creates a message-only error.bail!(MyError::Locked { id })returns a typed error that is still downcastable.- Prefer a typed error over
bail!when callers will want to react to this specific case.
4. Downcasting and inspecting the chain
fn exit_code_for(err: &anyhow::Error) -> u8 {
if let Some(e) = err.downcast_ref::<StoreError>() {
return match e {
StoreError::NotFound { .. } => 66, // EX_NOINPUT
_ => 74, // EX_IOERR
};
}
if let Some(io) = err.root_cause().downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::PermissionDenied {
return 77; // EX_NOPERM
}
}
1
}What downcast_ref::<T>() can see:
- the original error value passed into anyhow (
?conversion,Error::new,anyhow!(e)) - any context value added with
.context(C)(downcast toC, e.g. a&'static stror your own context struct) - not errors further down the
source()chain of that original error. For those, iterate:
let is_timeout = err.chain().any(|cause| {
cause.downcast_ref::<std::io::Error>().is_some_and(|e| e.kind() == std::io::ErrorKind::TimedOut)
});(Option::is_some_and is stable since Rust 1.70.)
Downcasting is for the few places that decide policy (exit codes, retry, HTTP mapping). If many call sites downcast the same type, that code wants a typed error instead of anyhow.
5. Report formats
Given Err(io_err).context("reading app.toml").context("loading configuration"):
| Format | Output |
|---|---|
{} | loading configuration |
{:#} | loading configuration: reading app.toml: No such file or directory (os error 2) |
{:?} | multi-line report (below) |
{:#?} | Rust struct-style Debug of the error and its parts; for debugging anyhow itself |
loading configuration
Caused by:
0: reading app.toml
1: No such file or directory (os error 2)
Stack backtrace:
0: ...The Stack backtrace: section appears only when a backtrace was captured. With a single cause the list is
not numbered.
For your own typed errors without anyhow, write the chain yourself:
pub fn report(err: &(dyn std::error::Error + 'static)) -> String {
let mut out = err.to_string();
let mut source = err.source();
while let Some(cause) = source {
out.push_str("\n caused by: ");
out.push_str(&cause.to_string());
source = cause.source();
}
out
}(std::error::Report does this in std but is unstable; do not depend on it.)
6. Backtraces
| Variable | Effect on anyhow / Backtrace::capture() | Effect on panics |
|---|---|---|
| unset | no capture | no backtrace |
RUST_BACKTRACE=1 | capture | short backtrace |
RUST_BACKTRACE=full | capture | full backtrace |
RUST_LIB_BACKTRACE=1 | capture (overrides RUST_BACKTRACE for errors) | unchanged |
RUST_LIB_BACKTRACE=0 with RUST_BACKTRACE=1 | no capture | backtrace |
- The setting is read once and cached; set it before the first error is created (in the environment, not midway through the program).
- Capturing costs microseconds to milliseconds; for errors on hot paths (cache misses as errors, parse
retries) keep it off in production or use
RUST_LIB_BACKTRACE=0. - Backtraces need debug info to be useful:
[profile.release] debug = "line-tables-only"gives file:line without full debug info size. Backtrace::force_capture()ignores the variables;Backtrace::status()tells whether one was captured.- Typed thiserror errors do not get backtraces automatically on stable; see thiserror-design.md.
7. main, ExitCode and Termination
Return anyhow::Result
fn main() -> anyhow::Result<()> {
let args = Args::parse();
run(args)
}On Err, std's Termination impl for Result<(), E: Debug> prints Error: {err:?} to stderr and exits
with code 1. With anyhow, {:?} is the full report (chain + backtrace). With a plain io::Error it is the
ugly Os { code: 2, kind: NotFound, message: "..." }, which is why main should return anyhow (or eyre)
rather than a raw std error.
Return ExitCode for custom codes and formatting
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}"); // one line for users
if std::env::var_os("APP_DEBUG").is_some() {
eprintln!("{err:?}"); // full report for developers
}
ExitCode::from(exit_code_for(&err))
}
}
}ExitCode and custom Termination impls are stable since Rust 1.61. Returning from main (unlike
std::process::exit) runs destructors: buffered writers flush, temp files are removed, tracing
subscribers flush.
Custom Termination
use std::process::{ExitCode, Termination};
enum Outcome {
Done,
NothingToDo,
Failed(anyhow::Error),
}
impl Termination for Outcome {
fn report(self) -> ExitCode {
match self {
Outcome::Done => ExitCode::SUCCESS,
Outcome::NothingToDo => ExitCode::from(3),
Outcome::Failed(err) => {
eprintln!("error: {err:?}");
ExitCode::FAILURE
}
}
}
}
fn main() -> Outcome {
match sync_all() {
Ok(0) => Outcome::NothingToDo,
Ok(_) => Outcome::Done,
Err(err) => Outcome::Failed(err),
}
}Async main
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cfg = load_config().await.context("loading configuration")?;
serve(cfg).await
}8. Logging errors once with tracing
Policy: handle, or add context and return. Never log and return. Logging at every layer produces the
same failure N times with partial context each time. Log at the boundary that decides the outcome (request
handler, job loop, main).
// worker loop: the boundary
loop {
let job = queue.next().await?;
if let Err(err) = process(&job).await {
tracing::error!(job_id = %job.id, error = %format!("{err:#}"), "job failed");
queue.nack(job.id).await?;
}
}| Field syntax | Records | For anyhow::Error |
|---|---|---|
error = %err | Display | outermost message only |
error = %format!("{err:#}") | Display of the chain | outer: inner: root (recommended) |
error = ?err | Debug | full multi-line report incl. backtrace |
error = &err as &(dyn std::error::Error + 'static) | the error as a tracing error value | for typed errors that are 'static; subscribers may render the chain |
#[tracing::instrument(err)] emits an ERROR event with the returned error's Display when the function
returns Err; err(Debug) uses Debug. If you use it, do not also log the same error in the caller.
#[tracing::instrument(skip(db), err)]
async fn create_user(db: &Db, email: &str) -> Result<UserId, ApiError> {
db.insert_user(email).await.context("inserting user").map_err(ApiError::from)
}Severity guidance: client mistakes (4xx) at debug/info (or not at all), dependency failures that were
retried successfully at warn, failures that reached the user at error.
9. Interop: Box dyn Error, String errors, closures
// anyhow -> Box<dyn Error + Send + Sync> (e.g. a framework that wants BoxError)
fn handler() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
inner().map_err(Into::into) // From<anyhow::Error> for Box<dyn Error + Send + Sync + 'static>
}
// Box<dyn Error + Send + Sync> -> anyhow: the anyhow! macro accepts it and keeps the source chain
fn call_plugin() -> anyhow::Result<()> {
plugin_run().map_err(|e| anyhow::anyhow!(e))
}
// String / &str errors -> anyhow
fn legacy() -> anyhow::Result<u32> {
old_api().map_err(anyhow::Error::msg) // old_api() -> Result<u32, String>
}
// anyhow::Error where &dyn Error is needed
fn log_it(err: &anyhow::Error) {
let e: &(dyn std::error::Error + Send + Sync + 'static) = err.as_ref();
eprintln!("{e}");
}Closures and async blocks often cannot infer the error type for ?:
let parsed = (|| {
let a: u32 = x.parse()?;
let b: u32 = y.parse()?;
anyhow::Ok(a + b) // pins the error type to anyhow::Error
})()?;
let task = tokio::spawn(async move {
let body = fetch(&url).await?;
Ok::<_, anyhow::Error>(body.len()) // same idea with turbofish
});
let len = task.await.context("fetch task panicked or was cancelled")??;10. Pitfalls
| Pitfall | Why it hurts | Fix |
|---|---|---|
.context(format!("reading {path}")) | allocates on success too | .with_context(|| format!(..)) |
context that repeats the error: .with_context(|| format!("failed: {e}")) | doubled text in reports | describe the operation only |
err.to_string() in logs | drops the chain | format!("{err:#}") or ?err |
anyhow::Error in library pub fn | callers cannot match | typed errors; see library-vs-application.md |
downcasting to a type wrapped deep inside another error's source() | downcast_ref only sees the top value and contexts | err.chain().find_map(|c| c.downcast_ref::<T>()) |
main printing with eprintln!("{err}") then exit(1) | loses chain, skips destructors | return the error or ExitCode |
unwrap() on serde_json::from_str in a CLI | panic message + backtrace instead of a clear error | .with_context(|| format!("parsing {path}"))? |
| expecting backtraces in release without debug info | frames show addresses only | debug = "line-tables-only" in release profile |
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-tokio-async-service-builder
Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.