Rust - Custom Errors
Overview
Estimated time: 40–50 minutes
Master creating custom error types in Rust using the Error trait, implementing Display and Debug, and leverage popular crates like anyhow and thiserror for ergonomic error handling in libraries and applications.
Learning Objectives
- Create custom error types implementing the Error trait.
- Design error hierarchies and error composition.
- Use the thiserror crate for ergonomic error definitions.
- Use the anyhow crate for simplified application error handling.
- Understand when to use different error handling approaches.
- Handle error context and source chains.
Prerequisites
The Error Trait
Custom error types should implement the std::error::Error
trait, which provides a standard interface for error handling:
use std::error::Error;
use std::fmt;
#[derive(Debug)]
enum MathError {
DivisionByZero,
NegativeSquareRoot,
Overflow,
}
impl fmt::Display for MathError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
MathError::DivisionByZero => write!(f, "Cannot divide by zero"),
MathError::NegativeSquareRoot => write!(f, "Cannot take square root of negative number"),
MathError::Overflow => write!(f, "Mathematical overflow occurred"),
}
}
}
impl Error for MathError {}
// Functions that use our custom error
fn divide(a: f64, b: f64) -> Result<f64, MathError> {
if b == 0.0 {
Err(MathError::DivisionByZero)
} else {
Ok(a / b)
}
}
fn sqrt(x: f64) -> Result<f64, MathError> {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn main() {
match divide(10.0, 0.0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
}
match sqrt(-4.0) {
Ok(result) => println!("Result: {}", result),
Err(e) => {
println!("Error: {}", e);
println!("Debug: {:?}", e);
}
}
}
Expected Output:
Error: Cannot divide by zero
Error: Cannot take square root of negative number
Debug: NegativeSquareRoot
Error Composition and Source Chains
Complex applications often need to compose multiple error types:
use std::error::Error;
use std::fmt;
use std::fs::File;
use std::io::{self, Read};
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
IoError(io::Error),
ParseError(ParseIntError),
InvalidValue(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::IoError(e) => write!(f, "I/O error: {}", e),
ConfigError::ParseError(e) => write!(f, "Parse error: {}", e),
ConfigError::InvalidValue(msg) => write!(f, "Invalid configuration value: {}", msg),
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&Box<dyn Error + 'static>> {
match self {
ConfigError::IoError(e) => Some(e),
ConfigError::ParseError(e) => Some(e),
ConfigError::InvalidValue(_) => None,
}
}
}
// Conversion implementations for easier error propagation
impl From<io::Error> for ConfigError {
fn from(error: io::Error) -> Self {
ConfigError::IoError(error)
}
}
impl From<ParseIntError> for ConfigError {
fn from(error: ParseIntError) -> Self {
ConfigError::ParseError(error)
}
}
struct Config {
max_connections: u32,
timeout: u32,
}
fn load_config(filename: &str) -> Result<Config, ConfigError> {
let mut contents = String::new();
let mut file = File::open(filename)?; // Automatically converts io::Error
file.read_to_string(&mut contents)?;
let lines: Vec<&str> = contents.lines().collect();
if lines.len() < 2 {
return Err(ConfigError::InvalidValue("Not enough configuration lines".to_string()));
}
let max_connections: u32 = lines[0].parse()?; // Automatically converts ParseIntError
let timeout: u32 = lines[1].parse()?;
if max_connections == 0 {
return Err(ConfigError::InvalidValue("Max connections cannot be zero".to_string()));
}
Ok(Config { max_connections, timeout })
}
fn print_error_chain(e: &dyn Error) {
println!("Error: {}", e);
let mut source = e.source();
while let Some(err) = source {
println!(" Caused by: {}", err);
source = err.source();
}
}
fn main() {
// Simulate different error scenarios
match load_config("nonexistent.txt") {
Ok(config) => println!("Config loaded: {} connections, {} timeout",
config.max_connections, config.timeout),
Err(e) => print_error_chain(&e),
}
}
Expected Output:
Error: I/O error: No such file or directory (os error 2)
Caused by: No such file or directory (os error 2)
Using thiserror for Ergonomic Error Definitions
The thiserror
crate eliminates boilerplate when creating custom errors. Add to Cargo.toml
:
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
enum DataProcessingError {
#[error("Network request failed")]
NetworkError(#[from] reqwest::Error),
#[error("Failed to parse JSON")]
JsonError(#[from] serde_json::Error),
#[error("Invalid data format: {message}")]
InvalidFormat { message: String },
#[error("Resource not found: {resource}")]
NotFound { resource: String },
#[error("Rate limit exceeded. Retry after {retry_after} seconds")]
RateLimited { retry_after: u64 },
#[error("Database error")]
DatabaseError(#[source] Box<dyn std::error::Error + Send + Sync>),
}
// Usage example
fn process_user_data(user_id: u32) -> Result<String, DataProcessingError> {
if user_id == 0 {
return Err(DataProcessingError::InvalidFormat {
message: "User ID cannot be zero".to_string(),
});
}
if user_id == 404 {
return Err(DataProcessingError::NotFound {
resource: format!("User {}", user_id),
});
}
if user_id == 429 {
return Err(DataProcessingError::RateLimited {
retry_after: 60,
});
}
Ok(format!("Processed user {}", user_id))
}
fn main() {
let test_cases = vec![1, 0, 404, 429];
for user_id in test_cases {
match process_user_data(user_id) {
Ok(result) => println!("Success: {}", result),
Err(e) => {
println!("Error: {}", e);
// Access the source error if available
if let Some(source) = e.source() {
println!(" Source: {}", source);
}
}
}
}
}
Expected Output:
Success: Processed user 1
Error: Invalid data format: User ID cannot be zero
Error: Resource not found: User 404
Error: Rate limit exceeded. Retry after 60 seconds
Using anyhow for Application Error Handling
The anyhow
crate is perfect for applications where you need flexible error handling. Add to Cargo.toml
:
[dependencies]
anyhow = "1.0"
use anyhow::{anyhow, Context, Result};
use std::fs::File;
use std::io::Read;
fn read_file_content(filename: &str) -> Result<String> {
let mut file = File::open(filename)
.with_context(|| format!("Failed to open file: {}", filename))?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.with_context(|| format!("Failed to read from file: {}", filename))?;
if contents.is_empty() {
return Err(anyhow!("File {} is empty", filename));
}
Ok(contents)
}
fn parse_config_value(line: &str) -> Result<(String, i32)> {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() != 2 {
return Err(anyhow!("Invalid config line format: '{}'", line));
}
let key = parts[0].trim().to_string();
let value: i32 = parts[1].trim().parse()
.with_context(|| format!("Failed to parse value '{}' as integer", parts[1]))?;
Ok((key, value))
}
fn load_and_parse_config(filename: &str) -> Result<Vec<(String, i32)>> {
let contents = read_file_content(filename)?;
let mut config = Vec::new();
for (line_num, line) in contents.lines().enumerate() {
if line.trim().is_empty() || line.starts_with('#') {
continue; // Skip empty lines and comments
}
let (key, value) = parse_config_value(line)
.with_context(|| format!("Error on line {}", line_num + 1))?;
config.push((key, value));
}
if config.is_empty() {
return Err(anyhow!("No valid configuration entries found"));
}
Ok(config)
}
fn main() -> Result<()> {
// Create a sample config file content in memory for demonstration
let sample_config = "max_connections=100\ntimeout=30\n# This is a comment\nretries=3\ninvalid_line\n";
// Simulate parsing the config
println!("Parsing sample configuration...");
let mut config = Vec::new();
for (line_num, line) in sample_config.lines().enumerate() {
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
match parse_config_value(line) {
Ok((key, value)) => {
println!(" {}: {}", key, value);
config.push((key, value));
}
Err(e) => {
println!("Error on line {}: {}", line_num + 1, e);
// Print the error chain
let mut source = e.source();
while let Some(err) = source {
println!(" Caused by: {}", err);
source = err.source();
}
}
}
}
// Test with nonexistent file
println!("\nTrying to load nonexistent file...");
match load_and_parse_config("nonexistent.txt") {
Ok(_) => println!("Loaded successfully"),
Err(e) => {
println!("Failed to load config: {}", e);
// Print error chain
for (i, cause) in e.chain().enumerate() {
if i == 0 {
continue; // Skip the main error (already printed)
}
println!(" {}: {}", i, cause);
}
}
}
Ok(())
}
Expected Output:
Parsing sample configuration...
max_connections: 100
timeout: 30
retries: 3
Error on line 5: Invalid config line format: 'invalid_line'
Trying to load nonexistent file...
Failed to load config: Failed to open file: nonexistent.txt
1: No such file or directory (os error 2)
Library vs Application Error Patterns
Library Pattern (using thiserror)
// In a library crate
use thiserror::Error;
#[derive(Error, Debug)]
pub enum HttpClientError {
#[error("Network timeout")]
Timeout,
#[error("Invalid URL: {url}")]
InvalidUrl { url: String },
#[error("HTTP {status}: {message}")]
HttpError { status: u16, message: String },
#[error("Serialization failed")]
SerializationError(#[from] serde_json::Error),
}
pub struct HttpClient;
impl HttpClient {
pub fn get(&self, url: &str) -> Result<String, HttpClientError> {
if !url.starts_with("http") {
return Err(HttpClientError::InvalidUrl {
url: url.to_string(),
});
}
// Simulate different responses
match url.contains("timeout") {
true => Err(HttpClientError::Timeout),
false => Ok("Response data".to_string()),
}
}
}
Application Pattern (using anyhow)
// In an application
use anyhow::{Context, Result};
fn fetch_user_profile(user_id: u32) -> Result<String> {
let client = HttpClient;
let url = format!("https://api.example.com/users/{}", user_id);
let response = client.get(&url)
.with_context(|| format!("Failed to fetch profile for user {}", user_id))?;
// Process response...
if response.is_empty() {
anyhow::bail!("Received empty response for user {}", user_id);
}
Ok(response)
}
fn main() -> Result<()> {
match fetch_user_profile(123) {
Ok(profile) => println!("Profile: {}", profile),
Err(e) => {
eprintln!("Application error: {}", e);
for cause in e.chain().skip(1) {
eprintln!(" Caused by: {}", cause);
}
}
}
Ok(())
}
Error Context and Debugging
use anyhow::{Context, Result};
use std::backtrace::Backtrace;
#[derive(Debug)]
struct DetailedError {
message: String,
backtrace: Backtrace,
}
impl std::fmt::Display for DetailedError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for DetailedError {
fn provide<'a>(&'a self, request: &mut std::error::Request<'a>) {
request.provide_ref(&self.backtrace);
}
}
fn risky_operation(should_fail: bool) -> Result<String> {
if should_fail {
return Err(DetailedError {
message: "Something went wrong in risky operation".to_string(),
backtrace: Backtrace::capture(),
}.into());
}
Ok("Success".to_string())
}
fn business_logic(fail: bool) -> Result<String> {
let result = risky_operation(fail)
.context("Business logic failed")?;
Ok(format!("Processed: {}", result))
}
fn main() -> Result<()> {
// Set environment variable to see backtraces
std::env::set_var("RUST_BACKTRACE", "1");
match business_logic(true) {
Ok(result) => println!("Success: {}", result),
Err(e) => {
println!("Error: {}", e);
// Print error chain
for (i, cause) in e.chain().enumerate() {
if i == 0 {
continue;
}
println!(" {}: {}", i, cause);
}
// Print backtrace if available
if let Some(backtrace) = e.backtrace() {
println!("\nBacktrace:\n{}", backtrace);
}
}
}
Ok(())
}
Best Practices
1. Choose the Right Approach
- Libraries: Use
thiserror
for specific, typed errors - Applications: Use
anyhow
for flexible error handling - Both: Implement
Error
trait for interoperability
2. Provide Good Error Messages
// Bad: Vague error message
Err(anyhow!("Operation failed"))
// Good: Specific, actionable error message
Err(anyhow!("Failed to connect to database at {}: {}", host, original_error))
3. Use Context Effectively
// Add context at each level
file.read_to_string(&mut contents)
.with_context(|| format!("Failed to read config file: {}", filename))?;
4. Design Error Hierarchies
// Group related errors logically
#[derive(Error, Debug)]
enum DatabaseError {
#[error("Connection failed")]
Connection(#[from] ConnectionError),
#[error("Query failed")]
Query(#[from] QueryError),
#[error("Transaction failed")]
Transaction(#[from] TransactionError),
}
Summary
Custom errors enable robust error handling in Rust:
- Error trait: Standard interface for all error types
- Display and Debug: Required for good error reporting
- thiserror: Ergonomic custom error definitions for libraries
- anyhow: Flexible error handling for applications
- Error chains: Preserve context through error propagation
- Context: Add meaningful information at each error level
- Best practices: Choose tools based on library vs application needs
Well-designed error types make debugging easier and provide clear guidance to users of your code.