Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

anyhow: Context, Downcasting and Reporting

Reference for rust-error-handling-thiserror-anyhow-designer · 367 lines

Programming / Rustv1.0.0Updated OCT 21 20267 references9 scripts

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

ItemSignature / behaviour
anyhow::Errorboxed 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?".

rust
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 bare No 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".
  • context for literals, with_context for 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 Option turns "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!

rust
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

rust
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 to C, e.g. a &'static str or your own context struct)
  • not errors further down the source() chain of that original error. For those, iterate:
rust
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"):

FormatOutput
{}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
text
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:

rust
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

VariableEffect on anyhow / Backtrace::capture()Effect on panics
unsetno captureno backtrace
RUST_BACKTRACE=1captureshort backtrace
RUST_BACKTRACE=fullcapturefull backtrace
RUST_LIB_BACKTRACE=1capture (overrides RUST_BACKTRACE for errors)unchanged
RUST_LIB_BACKTRACE=0 with RUST_BACKTRACE=1no capturebacktrace
  • 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

rust
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

rust
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

rust
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

rust
#[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).

rust
// 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 syntaxRecordsFor anyhow::Error
error = %errDisplayoutermost message only
error = %format!("{err:#}")Display of the chainouter: inner: root (recommended)
error = ?errDebugfull multi-line report incl. backtrace
error = &err as &(dyn std::error::Error + 'static)the error as a tracing error valuefor 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.

rust
#[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

rust
// 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 ?:

rust
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

PitfallWhy it hurtsFix
.context(format!("reading {path}"))allocates on success too.with_context(|| format!(..))
context that repeats the error: .with_context(|| format!("failed: {e}"))doubled text in reportsdescribe the operation only
err.to_string() in logsdrops the chainformat!("{err:#}") or ?err
anyhow::Error in library pub fncallers cannot matchtyped 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 contextserr.chain().find_map(|c| c.downcast_ref::<T>())
main printing with eprintln!("{err}") then exit(1)loses chain, skips destructorsreturn the error or ExitCode
unwrap() on serde_json::from_str in a CLIpanic message + backtrace instead of a clear error.with_context(|| format!("parsing {path}"))?
expecting backtraces in release without debug infoframes show addresses onlydebug = "line-tables-only" in release profile
More in Rust

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.