Guide contents

Lesson 50 of 94

thiserror & anyhow

The previous lesson wrote error types by hand: an enum, a Display impl, an Error impl, source(), and From conversions. It works, but it's a lot of boilerplate. Two small, ubiquitous crates remove it:

  • thiserror derives all of that from attributes. Use it for libraries and any code whose callers need to match on specific errors.
  • anyhow provides one flexible error type with easy context messages. Use it for applications, where errors are mostly reported rather than matched.

A common setup is thiserror in your library crates and anyhow in your binary.

Task Rust
Defining an error type #[derive(thiserror::Error)] on an enum
Adding context to an error .context("loading config")
Returning an error early bail!(msg)
Checking a condition ensure!(cond, msg)
Any error as the result type anyhow::Result<T>

Setup

Add both crates to Cargo.toml:

Terminal
cargo add thiserror anyhow
TOMLCargo.toml
[dependencies]
anyhow = "1"
thiserror = "2"

Part 1: thiserror

1. Deriving an error enum

#[derive(Error)] implements std::error::Error. Each #[error("...")] attribute becomes that variant's Display message.

Rust
use thiserror::Error;

#[derive(Debug, Error)]
enum PaymentError {
    #[error("card was declined")]
    Declined,
    #[error("card expired")]
    Expired,
    #[error("payment provider is unavailable")]
    ProviderDown,
}

fn charge(card: &str) -> Result<String, PaymentError> {
    match card {
        "4242" => Ok(String::from("ch_1")),
        "0000" => Err(PaymentError::Declined),
        "1111" => Err(PaymentError::Expired),
        _ => Err(PaymentError::ProviderDown),
    }
}

fn main() {
    for card in ["4242", "0000", "1111", "9999"] {
        match charge(card) {
            Ok(id) => println!("{card}: charged {id}"),
            Err(e) => println!("{card}: {e}"),
        }
    }
}
Output
4242: charged ch_1
0000: card was declined
1111: card expired
9999: payment provider is unavailable

2. Including fields in the message

Tuple fields are referred to as {0}, {1}; named fields by name. Format specs such as {:?} and {:.2} work too.

Rust
use thiserror::Error;

#[derive(Debug, Error)]
enum InventoryError {
    #[error("unknown SKU {0}")]
    UnknownSku(String),
    #[error("only {available} of {sku} left, {requested} requested")]
    Insufficient { sku: String, requested: u32, available: u32 },
    #[error("price {0:.2} is below the minimum")]
    PriceTooLow(f64),
}

fn main() {
    let errors = [
        InventoryError::UnknownSku(String::from("XX-9")),
        InventoryError::Insufficient { sku: String::from("KB-1"), requested: 5, available: 2 },
        InventoryError::PriceTooLow(0.4999),
    ];
    for e in &errors {
        println!("{e}");
    }
}
Output
unknown SKU XX-9
only 2 of KB-1 left, 5 requested
price 0.50 is below the minimum

3. #[from]: automatic conversions for ?

#[from] generates From<Inner> for YourError, so ? wraps the inner error, and it also sets the variant's source().

Rust
use std::num::{ParseFloatError, ParseIntError};
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("invalid integer")]
    Int(#[from] ParseIntError),
    #[error("invalid decimal")]
    Float(#[from] ParseFloatError),
    #[error("missing setting '{0}'")]
    Missing(&'static str),
}

fn load(workers: Option<&str>, ratio: &str) -> Result<(u32, f64), ConfigError> {
    let w = workers.ok_or(ConfigError::Missing("workers"))?.parse()?;
    let r = ratio.parse()?;
    Ok((w, r))
}

fn main() {
    use std::error::Error;
    println!("{:?}", load(Some("4"), "0.5"));
    for (w, r) in [(Some("four"), "0.5"), (Some("4"), "half"), (None, "0.5")] {
        let e = load(w, r).unwrap_err();
        println!("{e} (source: {:?})", e.source().map(|s| s.to_string()));
    }
}
Output
Ok((4, 0.5))
invalid integer (source: Some("invalid digit found in string"))
invalid decimal (source: Some("invalid float literal"))
missing setting 'workers' (source: None)

Compare this with example 7 of Custom Error Types: about 30 lines of impl blocks are gone.

4. #[source]: a cause without automatic conversion

When a variant holds a cause but also other fields (so From wouldn't make sense), mark the cause with #[source]. A field named source is treated as the source automatically.

Rust
use std::error::Error;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum ImportError {
    #[error("line {line}: invalid quantity '{value}'")]
    BadQuantity {
        line: usize,
        value: String,
        #[source]
        cause: ParseIntError,
    },
    #[error("line {line}: expected 2 columns, found {found}")]
    Columns { line: usize, found: usize },
}

fn parse(csv: &str) -> Result<Vec<(String, u32)>, ImportError> {
    csv.lines()
        .enumerate()
        .map(|(i, l)| {
            let cols: Vec<&str> = l.split(',').collect();
            let [name, qty] = cols[..] else {
                return Err(ImportError::Columns { line: i + 1, found: cols.len() });
            };
            let qty = qty.trim().parse().map_err(|cause| ImportError::BadQuantity { line: i + 1, value: qty.to_string(), cause })?;
            Ok((name.to_string(), qty))
        })
        .collect()
}

fn main() {
    println!("{:?}", parse("pen,3\nink,5"));
    let e = parse("pen,3\nink,five").unwrap_err();
    println!("{e}\n  caused by: {}", e.source().unwrap());
    println!("{}", parse("pen").unwrap_err());
}
Output
Ok([("pen", 3), ("ink", 5)])
line 2: invalid quantity 'five'
  caused by: invalid digit found in string
line 1: expected 2 columns, found 1

5. #[error(transparent)]: pass another error's message through

A transparent variant forwards Display and source to the inner error. It's useful for an "other" catch-all, or to wrap errors from a lower layer unchanged.

Rust
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("user {0} not found")]
    NotFound(u32),
    #[error(transparent)]
    Parse(#[from] std::num::ParseIntError),
}

fn find_user(raw_id: &str) -> Result<String, AppError> {
    let id: u32 = raw_id.parse()?;
    if id == 1 { Ok(String::from("ada")) } else { Err(AppError::NotFound(id)) }
}

fn main() {
    for raw in ["1", "42", "abc"] {
        match find_user(raw) {
            Ok(u) => println!("{raw}: {u}"),
            Err(e) => println!("{raw}: {e}"),
        }
    }
}
Output
1: ada
42: user 42 not found
abc: invalid digit found in string

6. Struct errors

thiserror works on structs as well as enums.

Rust
use thiserror::Error;

#[derive(Debug, Error)]
#[error("rate limit exceeded for {client}: {count} requests in {window_secs}s (max {max})")]
struct RateLimited {
    client: String,
    count: u32,
    max: u32,
    window_secs: u32,
}

fn check(client: &str, count: u32) -> Result<(), RateLimited> {
    if count > 100 {
        return Err(RateLimited { client: client.to_string(), count, max: 100, window_secs: 60 });
    }
    Ok(())
}

fn main() {
    println!("{:?}", check("app-1", 20));
    println!("{}", check("app-2", 250).unwrap_err());
}
Output
Ok(())
rate limit exceeded for app-2: 250 requests in 60s (max 100)

7. Matching on thiserror variants

The derived type is an ordinary enum, so callers match on it exactly as before.

Rust
use thiserror::Error;

#[derive(Debug, Error, PartialEq)]
enum LoginError {
    #[error("unknown user")]
    UnknownUser,
    #[error("wrong password ({attempts_left} attempts left)")]
    WrongPassword { attempts_left: u8 },
    #[error("account locked")]
    Locked,
}

fn login(user: &str, password: &str, failures: u8) -> Result<(), LoginError> {
    if user != "ada" {
        return Err(LoginError::UnknownUser);
    }
    if failures >= 3 {
        return Err(LoginError::Locked);
    }
    if password != "correct horse" {
        return Err(LoginError::WrongPassword { attempts_left: 2 - failures });
    }
    Ok(())
}

fn main() {
    let attempts = [("bob", "x", 0), ("ada", "guess", 0), ("ada", "guess", 2), ("ada", "correct horse", 3), ("ada", "correct horse", 1)];
    for (user, pw, failures) in attempts {
        let response = match login(user, pw, failures) {
            Ok(()) => String::from("welcome"),
            Err(LoginError::WrongPassword { attempts_left: 0 }) => String::from("last attempt failed, locking account"),
            Err(e @ LoginError::Locked) => format!("{e}: contact support"),
            Err(e) => e.to_string(),
        };
        println!("{user}: {response}");
    }
}
Output
bob: unknown user
ada: wrong password (2 attempts left)
ada: last attempt failed, locking account
ada: account locked: contact support
ada: welcome

Part 2: anyhow

8. anyhow::Result and ? on any error

anyhow::Result<T> is Result<T, anyhow::Error>. anyhow::Error can hold any error type, so ? works on everything, like Box<dyn Error> but with better features.

Rust
use anyhow::Result;

fn parse_pair(input: &str) -> Result<(u32, f64)> {
    let (a, b) = input.split_once(',').ok_or_else(|| anyhow::anyhow!("expected 'int,float'"))?;
    Ok((a.trim().parse()?, b.trim().parse()?))
}

fn main() {
    for input in ["3, 2.5", "3", "x, 2.5", "3, y"] {
        match parse_pair(input) {
            Ok(p) => println!("{input:>7}: {:?}", p),
            Err(e) => println!("{input:>7}: {e}"),
        }
    }
}
Output
 3, 2.5: (3, 2.5)
      3: expected 'int,float'
 x, 2.5: invalid digit found in string
   3, y: invalid float literal

9. Adding context with .context()

.context("message") wraps an error with a description of what you were trying to do. This is the single most useful feature of anyhow: errors explain themselves.

Rust
use anyhow::{Context, Result};

fn read_port(raw: &str) -> Result<u16> {
    raw.trim().parse::<u16>().context("port must be a number between 0 and 65535")
}

fn start_server(config_line: &str) -> Result<String> {
    let value = config_line.strip_prefix("port=").context("config line must start with 'port='")?;
    let port = read_port(value).context("reading server config")?;
    Ok(format!("listening on {port}"))
}

fn main() {
    for line in ["port=8080", "port=99999", "host=x"] {
        match start_server(line) {
            Ok(msg) => println!("{msg}"),
            Err(e) => println!("error: {e:#}"),
        }
    }
}
Output
listening on 8080
error: reading server config: port must be a number between 0 and 65535: number too large to fit in target type
error: config line must start with 'port='

.context() also works on Option, turning None into an error with that message. {:#} prints the whole chain on one line, separated by colons.

10. .with_context() for messages built at runtime

with_context(|| ...) builds the message only if there is an error, which avoids formatting strings on the happy path.

Rust
use anyhow::{Context, Result};

fn load_user(id: u32, db: &[(u32, &str)]) -> Result<String> {
    let name = db.iter().find(|(i, _)| *i == id).map(|(_, n)| n.to_string()).with_context(|| format!("user {id} does not exist"))?;
    Ok(name)
}

fn load_team(ids: &[u32], db: &[(u32, &str)]) -> Result<Vec<String>> {
    ids.iter().map(|&id| load_user(id, db).with_context(|| format!("loading team member #{id}"))).collect()
}

fn main() {
    let db = [(1, "ana"), (2, "ben")];
    println!("{:?}", load_team(&[1, 2], &db));
    println!("{:#}", load_team(&[1, 7, 2], &db).unwrap_err());
}
Output
Ok(["ana", "ben"])
loading team member #7: user 7 does not exist

11. bail!: return an error immediately

bail!("...") is shorthand for return Err(anyhow!("...")).

Rust
use anyhow::{bail, Result};

fn transfer(balance: i64, amount: i64) -> Result<i64> {
    if amount <= 0 {
        bail!("transfer amount must be positive, got {amount}");
    }
    if amount > balance {
        bail!("insufficient funds: balance {balance}, requested {amount}");
    }
    Ok(balance - amount)
}

fn main() {
    for amount in [50, -5, 500] {
        match transfer(100, amount) {
            Ok(left) => println!("ok, {left} left"),
            Err(e) => println!("error: {e}"),
        }
    }
}
Output
ok, 50 left
error: transfer amount must be positive, got -5
error: insufficient funds: balance 100, requested 500

12. ensure!: check a condition or fail

ensure!(condition, "message") returns an error when the condition is false, like Guava's Preconditions.checkArgument.

Rust
use anyhow::{ensure, Result};

fn create_account(username: &str, age: u32) -> Result<String> {
    ensure!(username.len() >= 3, "username '{username}' is too short");
    ensure!(username.chars().all(|c| c.is_ascii_alphanumeric()), "username may only contain letters and digits");
    ensure!(age >= 13, "users must be at least 13 years old");
    Ok(format!("created {username}"))
}

fn main() {
    for (name, age) in [("ferris", 8), ("al", 30), ("bad name", 30), ("ferris", 21)] {
        println!("{:?}", create_account(name, age).map_err(|e| e.to_string()));
    }
}
Output
Err("users must be at least 13 years old")
Err("username 'al' is too short")
Err("username may only contain letters and digits")
Ok("created ferris")

13. Three ways to print an anyhow::Error

  • {} prints only the outermost message.
  • {:#} prints the whole chain on one line.
  • {:?} prints the outermost message and a "Caused by:" list, the format used when main returns an error.
Rust
use anyhow::{Context, Result};

fn parse_config() -> Result<u32> {
    "12x".parse::<u32>().context("parsing 'workers'").context("loading app.toml")
}

fn main() {
    let e = parse_config().unwrap_err();
    println!("{{}}:   {e}");
    println!("{{:#}}: {e:#}");
    println!("{{:?}}:\n{e:?}");
}
Output
{}:   loading app.toml
{:#}: loading app.toml: parsing 'workers': invalid digit found in string
{:?}:
loading app.toml

Caused by:
    0: parsing 'workers'
    1: invalid digit found in string

14. main returning anyhow::Result

Returning an error from main prints it with the "Caused by:" list and exits with code 1.

Rustexits with an error
use anyhow::{Context, Result};

fn read_threshold(raw: &str) -> Result<f64> {
    raw.parse().with_context(|| format!("threshold '{raw}' is not a number"))
}

fn main() -> Result<()> {
    let t = read_threshold("0.75")?;
    println!("threshold {t}");
    let t = read_threshold("high").context("loading alert rules")?;
    println!("threshold {t}");
    Ok(())
}
Output
threshold 0.75
Error: loading alert rules

Caused by:
    0: threshold 'high' is not a number
    1: invalid float literal

15. Walking the chain

err.chain() iterates over every error in the chain, from outermost to root cause. err.root_cause() jumps to the end.

Rust
use anyhow::{Context, Result};

fn step3() -> Result<i32> {
    Ok("seven".parse::<i32>()?)
}

fn step2() -> Result<i32> {
    step3().context("reading retry count")
}

fn step1() -> Result<i32> {
    step2().context("starting worker pool")
}

fn main() {
    let e = step1().unwrap_err();
    for (depth, cause) in e.chain().enumerate() {
        println!("{depth}: {cause}");
    }
    println!("root cause: {}", e.root_cause());
}
Output
0: starting worker pool
1: reading retry count
2: invalid digit found in string
root cause: invalid digit found in string

16. Downcasting an anyhow::Error

An anyhow::Error still remembers the concrete errors inside it. downcast_ref::<T>() finds them, so an application can react to a specific library error.

Rust
use anyhow::{Context, Result};
use thiserror::Error;

#[derive(Debug, Error)]
enum StorageError {
    #[error("object {0} not found")]
    NotFound(String),
    #[error("storage quota exceeded")]
    QuotaExceeded,
}

fn get(key: &str) -> Result<String, StorageError> {
    match key {
        "logo.png" => Ok(String::from("PNG...")),
        "huge.bin" => Err(StorageError::QuotaExceeded),
        _ => Err(StorageError::NotFound(key.to_string())),
    }
}

fn handler(key: &str) -> Result<String> {
    get(key).with_context(|| format!("serving /files/{key}"))
}

fn main() {
    for key in ["logo.png", "missing.txt", "huge.bin"] {
        let status = match handler(key) {
            Ok(_) => 200,
            Err(e) => match e.downcast_ref::<StorageError>() {
                Some(StorageError::NotFound(_)) => 404,
                Some(StorageError::QuotaExceeded) => 507,
                None => 500,
            },
        };
        println!("{key}: {status}");
    }
}
Output
logo.png: 200
missing.txt: 404
huge.bin: 507

downcast_ref looks through context layers to find the original error.

17. anyhow! to create ad-hoc errors

anyhow!("...") creates an anyhow::Error from a message, useful in map_err, ok_or_else, and match arms.

Rust
use anyhow::{anyhow, Result};

fn parse_duration(s: &str) -> Result<u64> {
    let (num, unit) = s.split_at(s.find(|c: char| c.is_alphabetic()).ok_or_else(|| anyhow!("'{s}' has no unit"))?);
    let n: u64 = num.parse().map_err(|_| anyhow!("'{s}' does not start with a number"))?;
    match unit {
        "s" => Ok(n),
        "m" => Ok(n * 60),
        "h" => Ok(n * 3_600),
        other => Err(anyhow!("unknown unit '{other}' in '{s}'")),
    }
}

fn main() {
    for s in ["30s", "5m", "2h", "10", "xm", "3d"] {
        println!("{s:>3} -> {:?}", parse_duration(s).map_err(|e| e.to_string()));
    }
}
Output
30s -> Ok(30)
 5m -> Ok(300)
 2h -> Ok(7200)
 10 -> Err("'10' has no unit")
 xm -> Err("'xm' does not start with a number")
 3d -> Err("unknown unit 'd' in '3d'")

Part 3: Using them together

18. thiserror in the library, anyhow in the application

The library layer exposes a precise error enum. The application wraps it with context, and only matches on it where needed.

Rust
mod billing {
    use thiserror::Error;

    #[derive(Debug, Error)]
    pub enum BillingError {
        #[error("customer {0} has no payment method")]
        NoPaymentMethod(u32),
        #[error("amount {0} exceeds the single-charge limit")]
        OverLimit(u64),
    }

    pub fn charge(customer: u32, cents: u64) -> Result<String, BillingError> {
        if customer == 2 {
            return Err(BillingError::NoPaymentMethod(customer));
        }
        if cents > 1_000_000 {
            return Err(BillingError::OverLimit(cents));
        }
        Ok(format!("inv-{customer}-{cents}"))
    }
}

use anyhow::{Context, Result};

fn renew_subscription(customer: u32, cents: u64) -> Result<String> {
    let invoice = billing::charge(customer, cents).with_context(|| format!("renewing subscription for customer {customer}"))?;
    Ok(invoice)
}

fn main() {
    for (customer, cents) in [(1, 1_999), (2, 1_999), (3, 5_000_000)] {
        match renew_subscription(customer, cents) {
            Ok(inv) => println!("renewed: {inv}"),
            Err(e) => println!("failed: {e:#}"),
        }
    }
}
Output
renewed: inv-1-1999
failed: renewing subscription for customer 2: customer 2 has no payment method
failed: renewing subscription for customer 3: amount 5000000 exceeds the single-charge limit

19. Realistic example: a configuration loader with anyhow

Rust
use anyhow::{bail, ensure, Context, Result};

#[derive(Debug)]
struct Config {
    port: u16,
    workers: usize,
    database_url: String,
}

fn value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
    text.lines().filter_map(|l| l.split_once('=')).find(|(k, _)| k.trim() == key).map(|(_, v)| v.trim())
}

fn load(text: &str) -> Result<Config> {
    let port = value(text, "port").context("missing 'port'")?.parse().context("invalid 'port'")?;
    let workers: usize = value(text, "workers").unwrap_or("4").parse().context("invalid 'workers'")?;
    ensure!((1..=64).contains(&workers), "'workers' must be between 1 and 64, got {workers}");
    let database_url = value(text, "database_url").context("missing 'database_url'")?.to_string();
    if !database_url.starts_with("postgres://") {
        bail!("only postgres is supported, got '{database_url}'");
    }
    Ok(Config { port, workers, database_url })
}

fn main() {
    let files = [
        ("good.conf", "port=8080\nworkers=8\ndatabase_url=postgres://db/app"),
        ("no-port.conf", "database_url=postgres://db/app"),
        ("bad-workers.conf", "port=80\nworkers=500\ndatabase_url=postgres://db/app"),
        ("mysql.conf", "port=80\ndatabase_url=mysql://db/app"),
    ];
    for (name, text) in files {
        match load(text).with_context(|| format!("while loading {name}")) {
            Ok(c) => println!("{:?}", c),
            Err(e) => println!("error: {e:#}"),
        }
    }
}
Output
Config { port: 8080, workers: 8, database_url: "postgres://db/app" }
error: while loading no-port.conf: missing 'port'
error: while loading bad-workers.conf: 'workers' must be between 1 and 64, got 500
error: while loading mysql.conf: only postgres is supported, got 'mysql://db/app'

20. Realistic example: a service layer with thiserror

Rust
use thiserror::Error;

#[derive(Debug, Error)]
pub enum RepoError {
    #[error("row {0} not found")]
    NotFound(u64),
    #[error("database connection lost")]
    Disconnected,
}

#[derive(Debug, Error)]
pub enum UserServiceError {
    #[error("user {0} does not exist")]
    NoSuchUser(u64),
    #[error("{0} is already taken")]
    EmailTaken(String),
    #[error("storage error")]
    Storage(#[from] RepoError),
}

struct Repo {
    users: Vec<(u64, String)>,
    online: bool,
}

impl Repo {
    fn find(&self, id: u64) -> Result<&str, RepoError> {
        if !self.online {
            return Err(RepoError::Disconnected);
        }
        self.users.iter().find(|(i, _)| *i == id).map(|(_, e)| e.as_str()).ok_or(RepoError::NotFound(id))
    }
}

fn change_email(repo: &mut Repo, id: u64, new: &str) -> Result<String, UserServiceError> {
    if repo.users.iter().any(|(_, e)| e == new) {
        return Err(UserServiceError::EmailTaken(new.to_string()));
    }
    let old = match repo.find(id) {
        Ok(e) => e.to_string(),
        Err(RepoError::NotFound(id)) => return Err(UserServiceError::NoSuchUser(id)),
        Err(other) => return Err(other.into()),
    };
    if let Some(u) = repo.users.iter_mut().find(|(i, _)| *i == id) {
        u.1 = new.to_string();
    }
    Ok(format!("{old} -> {new}"))
}

fn main() {
    use std::error::Error;
    let mut repo = Repo { users: vec![(1, String::from("ana@x.com")), (2, String::from("ben@x.com"))], online: true };
    println!("{:?}", change_email(&mut repo, 1, "ana@new.com").map_err(|e| e.to_string()));
    println!("{:?}", change_email(&mut repo, 1, "ben@x.com").map_err(|e| e.to_string()));
    println!("{:?}", change_email(&mut repo, 9, "z@x.com").map_err(|e| e.to_string()));
    repo.online = false;
    let e = change_email(&mut repo, 2, "b@x.com").unwrap_err();
    println!("{e} (caused by: {})", e.source().unwrap());
}
Output
Ok("ana@x.com -> ana@new.com")
Err("ben@x.com is already taken")
Err("user 9 does not exist")
storage error (caused by: database connection lost)

The service translates RepoError::NotFound into its own NoSuchUser, but lets other storage errors pass through as Storage, keeping the original as the source.

21. Realistic example: HTTP errors with status codes

A thiserror enum plus a status() method gives one place that decides both the message and the status code of every failure.

Rust
use thiserror::Error;

#[derive(Debug, Error)]
enum ApiError {
    #[error("{resource} {id} not found")]
    NotFound { resource: &'static str, id: u64 },
    #[error("invalid request: {0}")]
    BadRequest(String),
    #[error("you do not have access to {0}")]
    Forbidden(String),
    #[error("internal error")]
    Internal(#[from] std::num::ParseIntError),
}

impl ApiError {
    fn status(&self) -> u16 {
        match self {
            ApiError::NotFound { .. } => 404,
            ApiError::BadRequest(_) => 400,
            ApiError::Forbidden(_) => 403,
            ApiError::Internal(_) => 500,
        }
    }
}

fn get_order(user: &str, raw_id: &str) -> Result<String, ApiError> {
    if raw_id.is_empty() {
        return Err(ApiError::BadRequest(String::from("missing order id")));
    }
    let id: u64 = raw_id.parse()?;
    match (user, id) {
        (_, 404) => Err(ApiError::NotFound { resource: "order", id }),
        ("guest", _) => Err(ApiError::Forbidden(format!("order {id}"))),
        _ => Ok(format!("{{\"order\": {id}}}")),
    }
}

fn main() {
    for (user, id) in [("ana", "7"), ("ana", "404"), ("guest", "7"), ("ana", ""), ("ana", "7x")] {
        match get_order(user, id) {
            Ok(body) => println!("200 {body}"),
            Err(e) => println!("{} {e}", e.status()),
        }
    }
}
Output
200 {"order": 7}
404 order 404 not found
403 you do not have access to order 7
400 invalid request: missing order id
500 internal error

22. Realistic example: a CLI tool's main with anyhow

The whole program reports every failure with full context, and exits with a non-zero code on error. This is how most Rust command-line tools are structured.

Rustexits with an error
use anyhow::{bail, Context, Result};

fn parse_line(line: &str) -> Result<(String, f64)> {
    let (item, price) = line.split_once(':').context("expected 'item: price'")?;
    let price: f64 = price.trim().parse().with_context(|| format!("price '{}' is not a number", price.trim()))?;
    if price < 0.0 {
        bail!("price cannot be negative");
    }
    Ok((item.trim().to_string(), price))
}

fn total(receipt: &str) -> Result<f64> {
    let mut sum = 0.0;
    for (n, line) in receipt.lines().enumerate() {
        let (_, price) = parse_line(line).with_context(|| format!("line {}: '{line}'", n + 1))?;
        sum += price;
    }
    Ok(sum)
}

fn main() -> Result<()> {
    println!("total {:.2}", total("coffee: 3.50\nbagel: 2.25").context("processing receipt-1")?);
    println!("total {:.2}", total("coffee: 3.50\nbagel two twenty-five").context("processing receipt-2")?);
    Ok(())
}
Output
total 5.75
Error: processing receipt-2

Caused by:
    0: line 2: 'bagel two twenty-five'
    1: expected 'item: price'

Summary

  • thiserror: #[derive(Error)] plus #[error("...")] messages, #[from] for automatic ? conversions, #[source] for causes, and #[error(transparent)] for pass-through variants. The result is an ordinary error enum callers can match.
  • anyhow: anyhow::Result<T> accepts any error through ?. Add meaning with .context() and .with_context(), fail early with bail! and ensure!, and create errors with anyhow!.
  • Print anyhow errors with {} (outermost), {:#} (whole chain on one line), or {:?} (with a "Caused by:" list).
  • downcast_ref recovers specific errors from an anyhow::Error, even under context layers.
  • Rule of thumb: libraries define precise errors with thiserror; applications report them with anyhow.

Next: Error Handling Patterns.