Rust Error Handling: Robust Program Design
Master Rust’s error handling with Result types.
Beyond the Basics: Result and Option
Let’s start with a quick refresher on Rust’s basic error handling types:
Result and Option Recap
// Result: for operations that can fail
fn divide(a: i32, b: i32) -> Result<i32, &'static str> {
if b == 0 {
Err("Division by zero")
} else {
Ok(a / b)
}
}
// Option: for values that might be absent
fn find_user(id: u64) -> Option<User> {
if id == 0 {
None
} else {
Some(User { id, name: "User".to_string() })
}
}
// Using Result and Option together
fn get_user_name(id: u64) -> Result<String, &'static str> {
find_user(id).map(|user| user.name).ok_or("User not found")
}
The ? Operator
fn process_data() -> Result<String, &'static str> {
let data = read_file("data.txt")?; // Returns early if Err
let processed = process_string(&data)?; // Returns early if Err
Ok(processed)
}
// Works with Option too
fn first_even_number(numbers: &[i32]) -> Option<i32> {
let first = numbers.first()?; // Returns None if empty
if first % 2 == 0 {
Some(*first)
} else {
None
}
}
Combining Result and Option
fn process_optional_data(data: Option<&str>) -> Result<String, &'static str> {
// Convert Option to Result
let data = data.ok_or("No data provided")?;
// Process the data
if data.is_empty() {
return Err("Data is empty");
}
Ok(data.to_uppercase())
}
// Using map and map_err for transformations
fn transform_data(input: Option<&str>) -> Result<i32, String> {
input
.ok_or("No input provided".to_string())
.map(|s| s.parse::<i32>())
.map_err(|e| format!("Invalid input: {}", e))?
.map_err(|e| format!("Parse error: {}", e))
}
Custom Error Types
For real-world applications, custom error types provide better organization and flexibility:
Basic Custom Error Type
#[derive(Debug)]
enum AppError {
IoError(std::io::Error),
ParseError(std::num::ParseIntError),
ValidationError(String),
NotFoundError(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::IoError(e) => write!(f, "I/O error: {}", e),
AppError::ParseError(e) => write!(f, "Parse error: {}", e),
AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
AppError::NotFoundError(item) => write!(f, "Not found: {}", item),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::IoError(e) => Some(e),
AppError::ParseError(e) => Some(e),
AppError::ValidationError(_) => None,
AppError::NotFoundError(_) => None,
}
}
}
// Implementing From for automatic conversions
impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> Self {
AppError::IoError(error)
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(error: std::num::ParseIntError) -> Self {
AppError::ParseError(error)
}
}
// Using the custom error type
fn read_and_parse(path: &str) -> Result<i32, AppError> {
let content = std::fs::read_to_string(path)?; // IoError converted automatically
let number = content.trim().parse::<i32>()?; // ParseError converted automatically
if number < 0 {
return Err(AppError::ValidationError("Number cannot be negative".to_string()));
}
Ok(number)
}
Using thiserror for Deriving Error Types
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("Parse error: {0}")]
ParseError(#[from] std::num::ParseIntError),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Not found: {0}")]
NotFoundError(String),
#[error("Database error: {0}")]
DatabaseError(#[from] DatabaseError),
}
#[derive(Error, Debug)]
enum DatabaseError {
#[error("Connection error: {0}")]
ConnectionError(String),
#[error("Query error: {0}")]
QueryError(String),
}
// Using the custom error type
fn process_user(user_id: u64) -> Result<User, AppError> {
let user = find_user(user_id)
.ok_or_else(|| AppError::NotFoundError(format!("User {}", user_id)))?;
if !is_valid_user(&user) {
return Err(AppError::ValidationError("Invalid user".to_string()));
}
Ok(user)
}
Fundamentals and Core Concepts
Structured Error Types
use thiserror::Error;
use std::path::PathBuf;
#[derive(Error, Debug)]
enum FileError {
#[error("File not found: {path}")]
NotFound { path: PathBuf },
#[error("Permission denied: {path}")]
PermissionDenied { path: PathBuf },
#[error("Invalid format in {path}: {message}")]
InvalidFormat { path: PathBuf, message: String },
#[error("I/O error: {source}")]
IoError {
#[from]
source: std::io::Error,
backtrace: std::backtrace::Backtrace,
},
}
fn read_config(path: PathBuf) -> Result<Config, FileError> {
if !path.exists() {
return Err(FileError::NotFound { path });
}
if !path.extension().map_or(false, |ext| ext == "json") {
return Err(FileError::InvalidFormat {
path,
message: "Expected JSON file".to_string(),
});
}
let content = std::fs::read_to_string(&path)?;
// Parse config...
Ok(Config { /* ... */ })
}
Error Context and Propagation
Adding context to errors makes them more useful for debugging and user feedback:
Using anyhow for Error Context
use anyhow::{Context, Result};
fn read_config_file(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)
.with_context(|| format!("Failed to parse config file: {}", path))?;
Ok(config)
}
fn main() -> Result<()> {
let config = read_config_file("config.json")
.context("Application configuration error")?;
// Use config...
Ok(())
}
Custom Context with thiserror
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("Failed to read config file: {path}")]
ReadError {
path: String,
#[source]
source: std::io::Error,
},
#[error("Failed to parse config file: {path}")]
ParseError {
path: String,
#[source]
source: serde_json::Error,
},
#[error("Missing required field: {field}")]
MissingField { field: String },
}
fn read_config_file(path: &str) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError {
path: path.to_string(),
source: e,
})?;
let config: Config = serde_json::from_str(&content)
.map_err(|e| ConfigError::ParseError {
path: path.to_string(),
source: e,
})?;
if config.api_key.is_empty() {
return Err(ConfigError::MissingField {
field: "api_key".to_string(),
});
}
Ok(config)
}
Error Chain Pattern
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct AppError {
message: String,
source: Option<Box<dyn Error + Send + Sync>>,
}
impl AppError {
fn new<M: Into<String>>(message: M) -> Self {
AppError {
message: message.into(),
source: None,
}
}
fn with_source<M: Into<String>, E: Error + Send + Sync + 'static>(
message: M,
source: E,
) -> Self {
AppError {
message: message.into(),
source: Some(Box::new(source)),
}
}
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl Error for AppError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source.as_ref().map(|s| s.as_ref() as &(dyn Error + 'static))
}
}
// Using the error chain
fn process_data(path: &str) -> Result<Data, AppError> {
let content = std::fs::read_to_string(path)
.map_err(|e| AppError::with_source(
format!("Failed to read file: {}", path),
e,
))?;
let data: Data = serde_json::from_str(&content)
.map_err(|e| AppError::with_source(
format!("Failed to parse file: {}", path),
e,
))?;
Ok(data)
}
Error Reporting and Handling
How errors are reported and handled can significantly impact user experience:
User-Friendly Error Messages
use thiserror::Error;
#[derive(Error, Debug)]
enum UserError {
#[error("We couldn't find your account. Please check your credentials.")]
AccountNotFound,
#[error("Your password is incorrect. Please try again.")]
InvalidPassword,
#[error("Your account is locked. Please contact support.")]
AccountLocked,
#[error("An internal error occurred. Please try again later.")]
InternalError(#[from] InternalError),
}
#[derive(Error, Debug)]
enum InternalError {
#[error("Database error: {0}")]
Database(String),
#[error("Network error: {0}")]
Network(String),
}
fn login(username: &str, password: &str) -> Result<User, UserError> {
let user = find_user(username)
.ok_or(UserError::AccountNotFound)?;
if !verify_password(&user, password) {
return Err(UserError::InvalidPassword);
}
if user.is_locked {
return Err(UserError::AccountLocked);
}
Ok(user)
}
// Handling errors in the UI
fn handle_login(username: &str, password: &str) {
match login(username, password) {
Ok(user) => {
println!("Welcome, {}!", user.name);
}
Err(e) => {
println!("Error: {}", e);
// Log internal errors for debugging
if let UserError::InternalError(internal) = &e {
log::error!("Internal error: {:?}", internal);
}
}
}
}
Advanced Patterns and Techniques
Error Logging
use log::{error, warn, info};
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("Configuration error: {0}")]
Config(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database error: {0}")]
Database(String),
}
fn process_request(request: Request) -> Result<Response, AppError> {
info!("Processing request: {:?}", request);
let config = load_config()
.map_err(|e| {
error!("Failed to load configuration: {}", e);
AppError::Config(format!("Failed to load configuration: {}", e))
})?;
let data = read_data(&request.path)
.map_err(|e| {
warn!("Failed to read data: {}", e);
e
})?;
let result = process_data(data, &config)
.map_err(|e| {
error!("Failed to process data: {}", e);
AppError::Database(format!("Failed to process data: {}", e))
})?;
info!("Request processed successfully");
Ok(Response::new(result))
}
Error Recovery Strategies
use std::time::Duration;
use std::thread;
// Retry pattern
fn with_retry<F, T, E>(mut operation: F, max_attempts: usize) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
E: std::fmt::Debug,
{
let mut attempts = 0;
let mut last_error = None;
while attempts < max_attempts {
match operation() {
Ok(value) => return Ok(value),
Err(e) => {
log::warn!("Operation failed (attempt {}): {:?}", attempts + 1, e);
last_error = Some(e);
attempts += 1;
if attempts < max_attempts {
let backoff = Duration::from_millis(100 * 2u64.pow(attempts as u32));
thread::sleep(backoff);
}
}
}
}
Err(last_error.unwrap())
}
// Fallback pattern
fn with_fallback<F, G, T, E>(primary: F, fallback: G) -> Result<T, E>
where
F: FnOnce() -> Result<T, E>,
G: FnOnce() -> Result<T, E>,
{
match primary() {
Ok(value) => Ok(value),
Err(e) => {
log::warn!("Primary operation failed, trying fallback: {:?}", e);
fallback()
}
}
}
Error Handling in Async Code
Async code introduces additional considerations for error handling:
Async Error Handling Basics
use tokio::fs;
use anyhow::{Context, Result};
async fn read_file(path: &str) -> Result<String> {
fs::read_to_string(path)
.await
.with_context(|| format!("Failed to read file: {}", path))
}
async fn process_file(path: &str) -> Result<()> {
let content = read_file(path).await?;
// Process content...
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let result = process_file("data.txt").await;
if let Err(e) = &result {
eprintln!("Error: {}", e);
// Print the error chain
let mut source = e.source();
while let Some(cause) = source {
eprintln!("Caused by: {}", cause);
source = cause.source();
}
}
result
}
Error Handling with Stream Processing
use futures::stream::{self, StreamExt, TryStreamExt};
use anyhow::{Context, Result};
async fn process_files(paths: Vec<String>) -> Result<Vec<String>> {
// Process files concurrently with error handling
let results = stream::iter(paths)
.map(|path| async move {
let content = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("Failed to read file: {}", path))?;
let processed = process_content(&content)
.with_context(|| format!("Failed to process file: {}", path))?;
Ok::<_, anyhow::Error>(processed)
})
.buffer_unordered(10) // Process up to 10 files concurrently
.collect::<Vec<_>>()
.await;
// Collect successful results and report errors
let mut processed_contents = Vec::new();
let mut error_count = 0;
for result in results {
match result {
Ok(content) => processed_contents.push(content),
Err(e) => {
error_count += 1;
eprintln!("Error: {}", e);
}
}
}
if error_count > 0 {
eprintln!("{} files failed to process", error_count);
}
Ok(processed_contents)
}
Domain-Specific Error Handling
Different domains may require specialized error handling approaches:
Web API Error Handling
use actix_web::{web, App, HttpResponse, HttpServer, ResponseError};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug)]
enum ApiError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Internal server error")]
InternalError(#[from] anyhow::Error),
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
code: u16,
}
impl ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
let (status_code, error_code) = match self {
ApiError::NotFound(_) => (actix_web::http::StatusCode::NOT_FOUND, 404),
ApiError::ValidationError(_) => (actix_web::http::StatusCode::BAD_REQUEST, 400),
ApiError::Unauthorized(_) => (actix_web::http::StatusCode::UNAUTHORIZED, 401),
ApiError::InternalError(_) => (actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, 500),
};
HttpResponse::build(status_code).json(ErrorResponse {
error: self.to_string(),
code: error_code,
})
}
}
#[derive(Deserialize)]
struct CreateUserRequest {
username: String,
email: String,
}
async fn create_user(req: web::Json<CreateUserRequest>) -> Result<HttpResponse, ApiError> {
// Validate request
if req.username.is_empty() {
return Err(ApiError::ValidationError("Username cannot be empty".to_string()));
}
if !req.email.contains('@') {
return Err(ApiError::ValidationError("Invalid email format".to_string()));
}
// Create user...
Ok(HttpResponse::Created().json(/* user */))
}
Implementation Strategies
CLI Application Error Handling
use std::process;
use thiserror::Error;
use clap::Parser;
#[derive(Error, Debug)]
enum CliError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Configuration error: {0}")]
Config(String),
#[error("Processing error: {0}")]
Processing(String),
}
#[derive(Parser)]
struct Args {
#[arg(short, long)]
config: String,
#[arg(short, long)]
verbose: bool,
}
fn run(args: Args) -> Result<(), CliError> {
// Load configuration
let config = load_config(&args.config)
.map_err(|e| CliError::Config(format!("Failed to load config: {}", e)))?;
// Process data
process_data(&config)
.map_err(|e| CliError::Processing(format!("Failed to process data: {}", e)))?;
Ok(())
}
fn main() {
let args = Args::parse();
if let Err(e) = run(args) {
eprintln!("Error: {}", e);
// Print detailed error information in verbose mode
if args.verbose {
if let Some(source) = e.source() {
eprintln!("Caused by: {}", source);
}
}
process::exit(1);
}
}
Best Practices for Error Handling
Based on experience from real-world Rust projects, here are some error handling best practices:
1. Design Error Types for Your Domain
use thiserror::Error;
// Domain-specific error types
#[derive(Error, Debug)]
enum DatabaseError {
#[error("Connection error: {0}")]
Connection(String),
#[error("Query error: {0}")]
Query(String),
#[error("Transaction error: {0}")]
Transaction(String),
}
#[derive(Error, Debug)]
enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Token expired")]
TokenExpired,
#[error("Insufficient permissions")]
InsufficientPermissions,
}
#[derive(Error, Debug)]
enum AppError {
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("Authentication error: {0}")]
Auth(#[from] AuthError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
2. Add Context to Errors
use anyhow::{Context, Result};
fn process_file(path: &str) -> Result<Data> {
// Add context to each operation
let file = std::fs::File::open(path)
.with_context(|| format!("Failed to open file: {}", path))?;
let reader = std::io::BufReader::new(file);
let data: Data = serde_json::from_reader(reader)
.with_context(|| format!("Failed to parse JSON from file: {}", path))?;
// Validate the data
if data.items.is_empty() {
anyhow::bail!("File contains no items: {}", path);
}
Ok(data)
}
3. Separate Internal and External Errors
use thiserror::Error;
// Internal errors (for logging and debugging)
#[derive(Error, Debug)]
enum InternalError {
#[error("Database error: {0}")]
Database(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
// External errors (for user-facing messages)
#[derive(Error, Debug)]
enum ApiError {
#[error("Resource not found")]
NotFound,
#[error("Invalid request: {0}")]
BadRequest(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Internal server error")]
Internal(#[from] InternalError),
}
4. Use Error Enums for Pattern Matching
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
fn handle_error(error: AppError) {
match error {
AppError::NotFound(item) => {
println!("Could not find: {}", item);
}
AppError::PermissionDenied(operation) => {
println!("You don't have permission to: {}", operation);
}
AppError::Io(e) => {
println!("I/O error: {}", e);
log::error!("I/O error details: {:?}", e);
}
}
}
Conclusion
Error handling in Rust goes far beyond the basic Result
and Option
types. By leveraging custom error types, error context, and domain-specific error handling patterns, you can create robust applications that gracefully handle failures and provide clear feedback to users. The patterns and practices discussed in this guide can help you design effective error handling strategies for your Rust applications, making them more maintainable, user-friendly, and resilient.
The key takeaways from this exploration of Rust error handling patterns are:
- Custom error types provide organization and flexibility for complex applications
- Error context helps with debugging and user feedback
- Error propagation can be simplified with the
?
operator and conversion traits - Error reporting should be tailored to the audience (users vs. developers)
- Domain-specific error handling addresses the unique needs of different application types
Remember that good error handling is not just about catching and reporting errors, but also about designing your code to be resilient and providing clear paths to recovery when things go wrong. By implementing these advanced error handling patterns in your Rust code, you can build applications that not only work correctly when everything goes as planned but also degrade gracefully when they encounter unexpected situations.