bookworm-smart-assistant/skills/rust-engineer/references/error-handling.md

8.3 KiB

Error Handling in Rust

Result and Option Basics

// Result: operation that can fail
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

// Option: value that might be absent
fn find_user(id: u64) -> Option<User> {
    if id == 1 {
        Some(User { id, name: "Alice".to_string() })
    } else {
        None
    }
}

// Using ? operator for propagation
fn calculate(a: f64, b: f64, c: f64) -> Result<f64, String> {
    let x = divide(a, b)?;  // Returns Err early if division fails
    let y = divide(x, c)?;
    Ok(y)
}

Custom Error Types

use std::fmt;

// Manual error type
#[derive(Debug)]
enum AppError {
    NotFound(String),
    InvalidInput(String),
    DatabaseError(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
            AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
            AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
        }
    }
}

impl std::error::Error for AppError {}

// Usage
fn get_user(id: u64) -> Result<User, AppError> {
    if id == 0 {
        return Err(AppError::InvalidInput("ID cannot be zero".to_string()));
    }
    // ... fetch user
    Err(AppError::NotFound(format!("User {} not found", id)))
}

Using thiserror

use thiserror::Error;

#[derive(Error, Debug)]
enum DataError {
    #[error("Data not found: {0}")]
    NotFound(String),

    #[error("Invalid ID: {id}, reason: {reason}")]
    InvalidId { id: u64, reason: String },

    #[error("IO error")]
    Io(#[from] std::io::Error),

    #[error("Parse error")]
    Parse(#[from] std::num::ParseIntError),

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
}

// Usage with automatic conversions
fn read_config(path: &str) -> Result<Config, DataError> {
    let content = std::fs::read_to_string(path)?;  // Auto-converts io::Error
    let port: u16 = content.parse()?;  // Auto-converts ParseIntError
    Ok(Config { port })
}

Using anyhow for Applications

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

// Simple error handling for applications
fn process_file(path: &str) -> Result<()> {
    let content = std::fs::read_to_string(path)
        .context(format!("Failed to read file: {}", path))?;

    ensure!(!content.is_empty(), "File is empty");

    if content.len() > 1000 {
        bail!("File too large");
    }

    // Process content...
    Ok(())
}

// Adding context to errors
fn main() -> Result<()> {
    process_file("config.txt")
        .context("Failed to process configuration")?;
    Ok(())
}

Option Combinators

// map: transform Option<T> to Option<U>
let num: Option<i32> = Some(5);
let doubled = num.map(|n| n * 2);  // Some(10)

// and_then: chain operations
let result = Some(5)
    .and_then(|n| if n > 0 { Some(n * 2) } else { None })
    .and_then(|n| Some(n + 1));  // Some(11)

// or: provide alternative
let value = None.or(Some(42));  // Some(42)

// unwrap_or: provide default
let value = None.unwrap_or(42);  // 42

// unwrap_or_else: compute default lazily
let value = None.unwrap_or_else(|| expensive_computation());

// filter: conditional None
let num = Some(5).filter(|&n| n > 10);  // None

// Pattern matching
match find_user(1) {
    Some(user) => println!("Found: {}", user.name),
    None => println!("User not found"),
}

// if let for simple cases
if let Some(user) = find_user(1) {
    println!("Found: {}", user.name);
}

Result Combinators

// map: transform Ok value
let result: Result<i32, String> = Ok(5);
let doubled = result.map(|n| n * 2);  // Ok(10)

// map_err: transform error
let result: Result<i32, &str> = Err("error");
let mapped = result.map_err(|e| e.to_uppercase());  // Err("ERROR")

// and_then: chain fallible operations
fn parse_then_double(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse::<i32>()
        .and_then(|n| Ok(n * 2))
}

// or_else: provide alternative computation
let result = Err("error").or_else(|_| Ok(42));  // Ok(42)

// unwrap_or: provide default
let value = Err("error").unwrap_or(42);  // 42

// expect: unwrap with custom panic message
let value = result.expect("Failed to parse number");

// Pattern matching
match divide(10.0, 2.0) {
    Ok(result) => println!("Result: {}", result),
    Err(e) => eprintln!("Error: {}", e),
}

Error Conversion and From Trait

use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum MyError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for MyError {
    fn from(err: io::Error) -> Self {
        MyError::Io(err)
    }
}

impl From<ParseIntError> for MyError {
    fn from(err: ParseIntError) -> Self {
        MyError::Parse(err)
    }
}

// Now ? operator works with automatic conversion
fn read_and_parse(path: &str) -> Result<i32, MyError> {
    let content = std::fs::read_to_string(path)?;  // io::Error -> MyError
    let number = content.trim().parse()?;  // ParseIntError -> MyError
    Ok(number)
}

Advanced Error Patterns

// Multiple error sources with Box<dyn Error>
use std::error::Error;

fn complex_operation() -> Result<String, Box<dyn Error>> {
    let file = std::fs::read_to_string("data.txt")?;
    let number: i32 = file.trim().parse()?;
    Ok(format!("Number: {}", number))
}

// Error with backtrace (nightly)
#[derive(Debug)]
struct DetailedError {
    message: String,
    backtrace: std::backtrace::Backtrace,
}

impl DetailedError {
    fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            backtrace: std::backtrace::Backtrace::capture(),
        }
    }
}

// Recoverable vs unrecoverable errors
fn might_fail(value: i32) -> Result<i32, String> {
    if value < 0 {
        Err("Negative value".to_string())  // Recoverable
    } else if value > 1000 {
        panic!("Value too large!");  // Unrecoverable
    } else {
        Ok(value * 2)
    }
}

Try Blocks (Nightly)

#![feature(try_blocks)]

// Try block for localized error handling
let result: Result<i32, Box<dyn Error>> = try {
    let file = std::fs::read_to_string("config.txt")?;
    let num: i32 = file.trim().parse()?;
    num * 2
};

Error Context Pattern

use thiserror::Error;

#[derive(Error, Debug)]
#[error("{message}")]
struct ContextError {
    message: String,
    #[source]
    source: Option<Box<dyn Error + Send + Sync>>,
}

impl ContextError {
    fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            source: None,
        }
    }

    fn with_source(mut self, source: impl Error + Send + Sync + 'static) -> Self {
        self.source = Some(Box::new(source));
        self
    }
}

// Extension trait for adding context
trait Context<T> {
    fn context(self, message: impl Into<String>) -> Result<T, ContextError>;
}

impl<T, E: Error + Send + Sync + 'static> Context<T> for Result<T, E> {
    fn context(self, message: impl Into<String>) -> Result<T, ContextError> {
        self.map_err(|e| ContextError::new(message).with_source(e))
    }
}

Best Practices

  • Use Result for recoverable errors, panic! for unrecoverable bugs
  • Prefer ? operator over unwrap() in production code
  • Use expect() with descriptive messages instead of unwrap()
  • Use thiserror for libraries (structured errors)
  • Use anyhow for applications (simple error handling)
  • Implement std::error::Error trait for custom error types
  • Add context to errors as they propagate up the stack
  • Use #[from] in thiserror for automatic conversions
  • Document error conditions in function documentation
  • Use Option::ok_or() to convert Option to Result
  • Use Result::ok() to convert Result to Option (discarding error)
  • Avoid String as error type (use custom types instead)
  • Use ensure! and bail! from anyhow for cleaner checks
  • Log errors at boundaries, return them in library code