background-shape
Error Handling in Rust, thiserror, anyhow, and the Patterns That Scale
July 21, 2023 · 8 min read · by Muhammad Amal programming

TL;DR — Use thiserror for libraries (typed errors, stable API), anyhow for application boundaries (one error type, rich context) / ? is the right default for propagation; add context with .context() or .with_context() so the chain is readable / Reserve panic! for genuinely unreachable cases; never panic on user input

The hardest part of Rust error handling isn’t the syntax. It’s deciding what your error types should look like, where the boundaries are, and how much context to preserve as errors travel up the stack. I’ve seen teams produce three completely different error designs in their first six months of Rust, and the one that survived was the simplest.

This post is the pattern that’s held up across a half-dozen production services. It assumes you’ve used ? and Result enough to know the basics — see my CLI post for an example of a real shape. The focus here is the design choices, not the syntax.

The two layers

There are two error-handling concerns in a real codebase, and they have different requirements:

Library / module code. Errors are part of the API. Callers may want to handle specific variants differently. Backward compatibility matters. Use thiserror to define a concrete enum.

Application / binary code. Errors are mostly logged and converted to user-facing messages or HTTP responses. The exact variant rarely matters at the top level. Use anyhow::Error (or your own equivalent) to carry whatever comes up.

Mixing these in one type is where the trouble starts. A service-level MyServiceError enum with 47 variants becomes a maintenance burden the moment you add a new dependency. An anyhow::Error at the library boundary makes it impossible for callers to handle errors meaningfully.

[dependencies]
thiserror = "1.0"
anyhow = "1.0"

thiserror for libraries

In a module or crate that other code calls, define an error enum. thiserror generates Display, Error, and From impls from a derive macro:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum UserRepoError {
    #[error("user not found: id={id}")]
    NotFound { id: i64 },

    #[error("email already in use: {email}")]
    DuplicateEmail { email: String },

    #[error("database error")]
    Database(#[from] sqlx::Error),

    #[error("invalid email format: {0}")]
    InvalidEmail(String),
}

pub async fn find_by_id(pool: &PgPool, id: i64) -> Result<User, UserRepoError> {
    let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(id)
        .fetch_optional(pool)
        .await?; // sqlx::Error -> UserRepoError::Database via #[from]
    row.ok_or(UserRepoError::NotFound { id })
}

A few design rules I follow for these enums:

  • One variant per failure mode the caller might branch on. If the caller is going to retry on “duplicate email” but bail on “database error,” those need distinct variants.
  • Don’t pre-emptively add variants you don’t have a use for. Adding later is easy; removing is breaking.
  • Wrap underlying errors with #[from] only when the conversion is unambiguous. If two different libraries return std::io::Error and they mean different things in your domain, don’t use #[from] — use a constructor.
  • Mark variants as non-exhaustive at the crate boundary. #[non_exhaustive] on the enum lets you add variants without a major version bump.
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum UserRepoError {
    // ... variants
}

anyhow for applications

At the top of a binary or in handler/orchestration code, you usually don’t care which library produced an error. You care about: did it fail, what’s the message chain, and what should the response be. anyhow::Error is built for this:

use anyhow::{Context, Result};

async fn handle_create_user(req: CreateUserRequest) -> Result<UserResponse> {
    let pool = get_pool().await?;
    let user = user_repo::create(&pool, &req.email, &req.name)
        .await
        .with_context(|| format!("creating user {}", req.email))?;
    let token = auth::issue_token(&user.id)
        .context("issuing session token")?;
    Ok(UserResponse { user, token })
}

The with_context calls add a layer to the error chain. When you log the error with {:#}, you get the full chain:

creating user alice@example.com: database error: connection refused (os error 111)

The with_context (closure) form is preferred over context (value) when the message has formatting — closures don’t execute on the success path, so you don’t pay the cost in the common case.

Returning anyhow from main

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();
    run_server().await.context("server crashed")?;
    Ok(())
}

This prints the full error chain on exit and returns code 1. For better control over the exit code and presentation, match on downcasts:

#[tokio::main]
async fn main() -> std::process::ExitCode {
    if let Err(e) = run().await {
        if let Some(repo) = e.downcast_ref::<UserRepoError>() {
            match repo {
                UserRepoError::NotFound { .. } => {
                    eprintln!("not found");
                    return std::process::ExitCode::from(66);
                }
                _ => {}
            }
        }
        eprintln!("{e:#}");
        return std::process::ExitCode::FAILURE;
    }
    std::process::ExitCode::SUCCESS
}

The boundary between the two

The pattern I’ve landed on: inside a module, Result<T, ModuleError> with a thiserror enum. Across modules in the same binary, you can convert into the calling module’s error or up to anyhow::Error at the orchestration boundary. The conversion is usually a #[from] or a single .context() call.

// In the user module
pub async fn create(pool: &PgPool, email: &str, name: &str) -> Result<User, UserRepoError> {
    // ...
}

// In the HTTP handler module
async fn create_user_handler(
    State(state): State<AppState>,
    Json(req): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
    let user = user_repo::create(&state.pool, &req.email, &req.name)
        .await?; // UserRepoError -> AppError via From
    Ok(Json(user))
}

// AppError sits at the HTTP boundary and knows how to render to a response
#[derive(Debug)]
pub struct AppError(anyhow::Error);

impl<E> From<E> for AppError
where E: Into<anyhow::Error> {
    fn from(e: E) -> Self { Self(e.into()) }
}

The AppError newtype around anyhow::Error is a useful trick — it lets you implement IntoResponse for axum (or whatever HTTP layer you use) without orphan-rule problems, and it gives you a single place to handle error rendering, status codes, and logging.

Status codes from typed errors

For HTTP services, the typed error layer is where you decide what an error becomes externally:

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code) = match self.0.downcast_ref::<UserRepoError>() {
            Some(UserRepoError::NotFound { .. }) => (StatusCode::NOT_FOUND, "user_not_found"),
            Some(UserRepoError::DuplicateEmail { .. }) => (StatusCode::CONFLICT, "duplicate_email"),
            Some(UserRepoError::InvalidEmail(_)) => (StatusCode::BAD_REQUEST, "invalid_email"),
            _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
        };

        // Log the full chain, return a sanitized body
        if status.is_server_error() {
            tracing::error!("{:#}", self.0);
        } else {
            tracing::warn!("{:#}", self.0);
        }

        let body = serde_json::json!({
            "error": { "code": code, "message": status.canonical_reason() }
        });
        (status, Json(body)).into_response()
    }
}

A few rules in this layer:

  • 5xx errors log the chain; 4xx errors don’t (or log at warn). Server errors are your problem; client errors are noise unless you’re tracking abuse patterns.
  • The response body is sanitized. Never put format!("{:#}", err) into a response — it can leak internals like SQL fragments, file paths, or partial secrets.
  • Status codes are deterministic from the error type. If two different error types map to the same status, that’s fine; if one error type can produce two statuses, you have a design problem.

Panics: when and where

A panic! unwinds (or aborts) the current thread. In an async server, that means tokio will drop the task — other tasks keep running. That’s the bug, actually: a panic in a handler will return a generic 500 from axum without you knowing the root cause unless you’ve configured the right tracing layer.

Use panic! only when the invariant being violated means the program state is unsafe to continue with. Examples:

  • A config file said pool_size = 0. Panic, refuse to start.
  • An internal invariant in your own data structure is broken. Panic; bug in your code.

Don’t panic on:

  • User input. Return a 400.
  • A flaky downstream. Return a 5xx, increment a metric.
  • A missing optional field. Return a typed error.

The expect() form is preferable to unwrap() because it forces you to write the invariant in code:

let port: u16 = std::env::var("PORT")
    .expect("PORT env var must be set")
    .parse()
    .expect("PORT must be a valid u16");

Catching panics at the task boundary

For long-running services, wrapping spawned tasks in tokio::task::Builder or catching panics in the join can prevent a panic in one task from being a silent failure:

let handle = tokio::spawn(async move {
    do_work().await
});

match handle.await {
    Ok(Ok(())) => {}
    Ok(Err(e)) => tracing::error!("task failed: {e:#}"),
    Err(join_err) if join_err.is_panic() => {
        tracing::error!("task panicked: {join_err}");
        // optionally alert / increment metric
    }
    Err(e) => tracing::error!("task cancelled: {e}"),
}

Common Pitfalls

  • ? losing context. If you propagate an error five layers up without any .context() calls, the final log is “no such file or directory” with no idea which file. Add context at boundaries: when entering a module, when calling out to a service, when processing a specific item.
  • Boxed errors as your default. Box<dyn Error> is the std-lib hammer; anyhow::Error is strictly more useful (it carries a chain, Debug prints nicely, downcasts work). Use anyhow unless you have a reason not to.
  • Comparing errors with strings. If you find yourself matching on err.to_string().contains("not found"), you have a typed error problem. Add a variant or do a proper downcast.
  • Logging the same error twice. If a handler logs the error and the framework also logs it, you’ll have duplicate noise. Pick one layer to be the source of truth.
  • Using panic = "abort" in a library. Set it in binary crates only. Libraries should be neutral about whether the caller wants unwinding.
  • Forgetting to make errors Send + Sync + 'static. Required for anyhow::Error interop and for crossing tokio task boundaries. thiserror enums usually get this for free; check if you embed weird types.

Wrapping Up

The two-layer split — typed errors in modules, anyhow at the orchestration layer, an HTTP-aware wrapper at the boundary — is what’s survived contact with real codebases for me. The pattern is boring on purpose; error handling that’s clever is error handling that breaks on the next refactor. The thiserror docs and anyhow docs cover the API surface; the design choices above are what makes them work together.

Next post is building axum 0.6 APIs that put all of this together: typed errors, structured logging, the request shape that doesn’t fall over under real traffic.