The Book·Chapter 9·14 min

Error handling

Panic vs Result, the ? operator, From conversions, thiserror, anyhow, and what to reject.

Rust splits failure into two categories. Unrecoverable failures panic! and unwind the stack (or abort the process). Recoverable failures return a Result<T, E>. The split is enforced by the type system. A function that returns Result cannot accidentally throw. A function that doesn't return Result cannot accidentally return an error. There is no hidden control flow.

For an agent orchestrator, this is the most consequential chapter in the book. Every .unwrap() an agent writes in library code is a contract violation. Every Box<dyn Error> in a library API erases information that callers needed. Most production Rust bugs trace back to error handling decisions, not borrow checker fights.

Panic: the runtime gives up

panic! is for bugs. The state is unrecoverable, the program cannot meaningfully continue, and exiting is the safe outcome.

fn divide(numerator: i32, denominator: i32) -> i32 {
    if denominator == 0 {
        panic!("denominator was zero, this is a bug");
    }
    numerator / denominator
}

By default a panic unwinds the stack, running destructors. In Cargo.toml you can switch to abort:

[profile.release]
panic = "abort"

Aborting is smaller and faster but skips destructors. Sail uses unwinding because some destructors flush state. Pick deliberately.

Implicit panics are scattered through the standard library: out-of-bounds indexing (v[i]), integer overflow in debug builds, .unwrap(), .expect(), unreachable!(), todo!(), slice operations on empty slices. An agent will use these casually unless told not to.

Result: failure as a value

enum Result<T, E> {
    Ok(T),
    Err(E),
}

The caller cannot ignore the failure path. It is part of the return type. Match it, propagate it, or convert it explicitly.

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.parse::<u16>()
}
 
match parse_port("8080") {
    Ok(p) => println!("port {p}"),
    Err(e) => eprintln!("bad port: {e}"),
}

The ? operator

? is the spine of real Rust error handling. On Err, it returns the error from the current function after running a From conversion. On Ok, it unwraps and continues.

use std::fs;
use std::io;
 
fn read_config(path: &str) -> Result<String, io::Error> {
    let raw = fs::read_to_string(path)?;
    let trimmed = raw.trim().to_string();
    Ok(trimmed)
}

? works across error types when there is a From<E1> for E2 impl. This is how a function that reads a file and parses JSON returns a single error type.

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("parse: {0}")]
    Parse(#[from] serde_json::Error),
}
 
fn load(path: &str) -> Result<Config, ConfigError> {
    let raw = std::fs::read_to_string(path)?;
    let cfg: Config = serde_json::from_str(&raw)?;
    Ok(cfg)
}

#[from] on a variant generates the From impl. Each ? picks the right conversion. No manual match chain.

main returning Result

Binaries can return Result from main. The runtime prints the error and exits non-zero.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = load("config.toml")?;
    run(cfg)?;
    Ok(())
}

Box<dyn Error> is fine here, in the binary's main. It is not fine inside a library.

thiserror for libraries, anyhow for applications

Two crates dominate. Pick by audience.

thiserror generates the Error impl, Display impl, and From impls for a typed enum. Use it when callers might match on the variant.

#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
    #[error("not found: {0}")]
    NotFound(String),
    #[error("already exists: {0}")]
    AlreadyExists(String),
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
}

anyhow wraps any error in a single dynamic type with context. Use it in binaries and integration glue code where callers will not match.

use anyhow::Context;
 
fn main() -> anyhow::Result<()> {
    let cfg = load("config.toml").context("loading config")?;
    run(cfg).context("running server")?;
    Ok(())
}

Rule of thumb: thiserror in library crates, anyhow in binary crates. Mixing them is a smell.

Bad vs good

△ Bad
pub fn parse_port(s: &str) -> u16 {
  s.parse().unwrap()
}

pub fn load_config(path: &str) -> Config {
  let raw = std::fs::read_to_string(path).unwrap();
  serde_json::from_str(&raw).unwrap()
}
◇ Good
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
  #[error("port out of range: {0}")]
  Port(String),
  #[error("io: {0}")]
  Io(#[from] std::io::Error),
  #[error("parse: {0}")]
  Parse(#[from] serde_json::Error),
}

pub fn parse_port(s: &str) -> Result<u16, ConfigError> {
  s.parse().map_err(|_| ConfigError::Port(s.to_string()))
}

pub fn load_config(path: &str) -> Result<Config, ConfigError> {
  let raw = std::fs::read_to_string(path)?;
  Ok(serde_json::from_str(&raw)?)
}