Error Handling Patterns
Error Design Philosophy
Section titled “Error Design Philosophy”Rust treats errors as values, not exceptions. This is a fundamental design choice: errors are not Special control flow mechanisms that can jump across function boundaries. They are ordinary values That propagate through the type system via Result<T, E>. This makes error paths explicit and force The programmer to handle them.
The core principle: make error states unrepresentable where possible, and where they are Representable, make them unignorable.
Errors as Values vs Exceptions
Section titled “Errors as Values vs Exceptions”In exception-based languages (Java, Python, C++), error handling is opt-in — you can ignore Exceptions and they propagate implicitly. In Rust, Result forces you to acknowledge errors at Every level of the call stack. The ? operator makes propagation ergonomic, but the type system Still tracks the error type.
// Every error is visible in the type signaturefn read_config(path: &str) -> Result<Config, io::Error> { ... }fn parse_config(content: &str) -> Result<Config, serde_json::Error> { ... }fn validate_config(config: &Config) -> Result<(), ValidationError> { ... }Defining Error Types
Section titled “Defining Error Types”Enum-Based Error Types
Section titled “Enum-Based Error Types”The most common approach is an enum with a variant for each error category:
use std::fmt;
#[derive(Debug)]enum AppError { Io(std::io::Error), Parse(std::num::ParseIntError), Validation(String), NotFound(String), PermissionDenied(String), Internal(String),}
impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { AppError::Io(e) => write!(f, "I/O error: {}", e), AppError::Parse(e) => write!(f, "parse error: {}", e), AppError::Validation(msg) => write!(f, "validation error: {}", msg), AppError::NotFound(msg) => write!(f, "not found: {}", msg), AppError::PermissionDenied(msg) => write!(f, "permission denied: {}", msg), AppError::Internal(msg) => write!(f, "internal error: {}", msg), } }}
impl std::error::Error for AppError { fn source(&self) -> Option<&(dyn std::error::Error + "static)> { match self { AppError::Io(e) => Some(e), AppError::Parse(e) => Some(e), _ => None, } }}From Implementations for ? Propagation
Section titled “From Implementations for ? Propagation”Each From implementation enables the ? operator to automatically convert the source error type Into your error type:
impl From<std::io::Error> for AppError { fn from(e: std::io::Error) -> Self { AppError::Io(e) }}
impl From<std::num::ParseIntError> for AppError { fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }}
impl From<serde_json::Error> for AppError { fn from(e: serde_json::Error) -> Self { AppError::Parse(e) }}With these implementations, the ? operator handles conversions automatically:
fn load_config(path: &str) -> Result<Config, AppError> { let content = std::fs::read_to_string(path)?; // io::Error -> AppError::Io let config: Config = serde_json::from_str(&content)?; // serde_json::Error -> AppError::Parse Ok(config)}Error Chaining
Section titled “Error Chaining”std::error::Error::source()
Section titled “std::error::Error::source()”The source() method enables walking an error chain. Each error can optionally return a reference To the underlying error that caused it:
use std::error::Error;use std::fmt;
#[derive(Debug)]struct DatabaseError { message: String, source: sqlx::Error,}
impl fmt::Display for DatabaseError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.message, self.source) }}
impl Error for DatabaseError { fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.source) }}Walking the chain:
fn print_error_chain(err: &dyn Error) { eprintln!("error: {}", err); let mut source = err.source(); while let Some(cause) = source { eprintln!(" caused by: {}", cause); source = cause.source(); }}Backtrace Support (Rust 1.65+)
Section titled “Backtrace Support (Rust 1.65+)”use std::backtrace::Backtrace;use std::fmt;
#[derive(Debug)]struct DetailedError { message: String, backtrace: Backtrace,}
impl fmt::Display for DetailedError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}\nBacktrace:\n{}", self.message, self.backtrace) }}Backtrace::capture() captures the current stack trace. It is available when RUST_BACKTRACE=1 is Set. The backtrace is only captured if an environment variable enables it, so there is no overhead In production by default.
thiserror vs anyhow Decision Framework
Section titled “thiserror vs anyhow Decision Framework”Use thiserror for Libraries
Section titled “Use thiserror for Libraries”Libraries need precise, typed error types that callers can match on:
use thiserror::Error;
#[derive(Error, Debug)]pub enum DatabaseError { #[error("connection failed: {0}")] Connection(#[from] sqlx::Error),
#[error("query failed: {query}")] Query { query: String, #[source] source: sqlx::Error },
#[error("row not found: table={table}, key={key}")] NotFound { table: String, key: String },
#[error("timeout after {timeout_ms}ms")] Timeout { timeout_ms: u64 },}Use anyhow for Applications
Section titled “Use anyhow for Applications”Applications need ergonomic error propagation with context, not precise error matching:
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read config file: {}", path))?;
let config: Config = serde_json::from_str(&content) .context("failed to parse config as JSON")?;
Ok(config)}The Hybrid Approach
Section titled “The Hybrid Approach”Use thiserror in library crates and anyhow in the application binary that consumes them:
my-project/├── crates/│ ├── core/ # uses thiserror for precise error types│ │ └── src/│ │ └── error.rs│ └── cli/ # uses anyhow for ergonomic error handling│ └── src/│ └── main.rsuse thiserror::Error;
#[derive(Error, Debug)]pub enum CoreError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("parse error: {0}")] Parse(#[from] serde_json::Error),}
// crates/cli/src/main.rsuse anyhow::Result;use my_core::{Config, CoreError};
fn main() -> Result<()> { let config = load_config("config.toml")?; println!("config: {:?}", config); Ok(())}
fn load_config(path: &str) -> Result<Config> { let content = std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("failed to read {}: {}", path, e))?; let config: Config = serde_json::from_str(&content)?; Ok(config)}Retry Patterns
Section titled “Retry Patterns”Exponential Backoff
Section titled “Exponential Backoff”use std::time::Duration;use std::thread;
fn with_retry<F, T, E>(max_retries: usize, mut f: F) -> Result<T, E>where F: FnMut() -> Result<T, E>, E: std::fmt::Debug,{ let mut attempt = 0; loop { match f() { Ok(value) => return Ok(value), Err(e) => { attempt += 1; if attempt >= max_retries { return Err(e); } let delay = Duration::from_millis(100 * 2u64.pow(attempt as u32 - 1)); thread::sleep(delay); } } }}Retry with Specific Error Types
Section titled “Retry with Specific Error Types”use std::io;
fn fetch_with_retry(url: &str, max_retries: usize) -> Result<String, io::Error> { let mut retries = 0; loop { match std::fs::read_to_string(url) { Ok(content) => return Ok(content), Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(e), Err(e) => { retries += 1; if retries >= max_retries { return Err(e); } let delay = std::time::Duration::from_millis(100 * (1 << retries)); std::thread::sleep(delay); } } }}Async Retry with tokio
Section titled “Async Retry with tokio”use tokio::time::{sleep, Duration};
async fn async_retry<F, Fut, T, E>( max_retries: usize, f: F,) -> Result<T, E>where F: Fn() -> Fut, Fut: std::future::Future<Output = Result<T, E>>, E: std::fmt::Debug,{ let mut attempt = 0; loop { match f().await { Ok(value) => return Ok(value), Err(e) => { attempt += 1; if attempt >= max_retries { return Err(e); } let delay = Duration::from_millis(100 * 2u64.pow(attempt as u32 - 1)); sleep(delay).await; } } }}Error handling patterns in Rust build on Result and Option to create robust applications. The map_err function transforms error types without unwrapping. and_then chains operations that might fail. The anyhow crate provides dynamic error types for applications, while thiserror facilitates library error types. Combining these patterns with the ? operator creates clean error propagation that the compiler verifies exhaustively.
Cross-References
Section titled “Cross-References”- [[rust/04-error-handling/error-handling]] - Result and Option fundamentals
- [[rust/02-ownership-borrowing/ownership]] - Ownership semantics in error chains
- [[rust/05-traits-generics/traits-and-generics]] - Trait-based error conversion
- [[rust/03-structs-enums/advanced-patterns]] - Pattern matching on error variants