Error Composition

Using thiserror and anyhow effectively

intermediate
thiserroranyhowcomposition
🎮 Interactive Playground

Why Error Composition?

Real applications have errors from multiple sources: I/O, parsing, network, database. Error composition helps you:

  • Combine errors from different libraries
  • Add context to errors
  • Convert between error types
  • Maintain error chains
The Challenge:
fn process_file() -> Result<Data, ???> {  // What error type?
    let contents = std::fs::read_to_string("file.txt")?;  // io::Error
    let data = serde_json::from_str(&contents)?;           // serde_json::Error
    Ok(data)
}
Two Solutions:
  1. thiserror: For library code - define your own error types
  2. anyhow: For application code - dynamic error handling

Pattern 1: thiserror for Libraries

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataProcessError {
    #[error("Failed to read file: {path}")]
    FileRead {
        path: String,
        #[source]
        source: std::io::Error,
    },

    #[error("JSON parse error at line {line}")]
    JsonParse {
        line: usize,
        #[source]
        source: serde_json::Error,
    },

    #[error("Invalid data: {0}")]
    Validation(String),

    #[error(transparent)]
    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
}

// Usage
fn process_file(path: &str) -> Result<Data, DataProcessError> {
    let contents = std::fs::read_to_string(path)
        .map_err(|e| DataProcessError::FileRead {
            path: path.to_string(),
            source: e,
        })?;

    let data: Data = serde_json::from_str(&contents)
        .map_err(|e| DataProcessError::JsonParse {
            line: e.line(),
            source: e,
        })?;

    if !data.is_valid() {
        return Err(DataProcessError::Validation("Invalid checksum".into()));
    }

    Ok(data)
}

thiserror Attributes:

| Attribute | Purpose | Example |

|-----------|---------|---------|

| #[error("...")] | Display message | #[error("Not found: {id}")] |

| #[source] | Mark error source | #[source] source: io::Error |

| #[from] | Auto From impl | #[from] io::Error |

| #[transparent] | Forward Display/Error | #[error(transparent)] |

Pattern 2: anyhow for Applications

use anyhow::{Context, Result};

fn process_user_data(user_id: u64) -> Result<()> {
    // Automatically converts any error type
    let user_file = format!("users/{}.json", user_id);

    let contents = std::fs::read_to_string(&user_file)
        .context(format!("Failed to read user file: {}", user_file))?;

    let user: User = serde_json::from_str(&contents)
        .context("Failed to parse user JSON")?;

    save_to_database(&user)
        .context(format!("Failed to save user {} to database", user_id))?;

    Ok(())
}

// Error handling
fn main() {
    if let Err(e) = process_user_data(123) {
        eprintln!("Error: {:?}", e);

        // Print error chain
        for cause in e.chain() {
            eprintln!("  Caused by: {}", cause);
        }
    }
}

Real-World Example 1: Web API Error Handling (Web)

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Redis cache error: {0}")]
    Cache(#[from] redis::RedisError),

    #[error("JSON serialization error")]
    Serialization(#[from] serde_json::Error),

    #[error("Authentication failed: {reason}")]
    AuthFailed { reason: String },

    #[error("Resource not found: {resource_type} with id {id}")]
    NotFound { resource_type: String, id: String },

    #[error("Rate limit exceeded: {retry_after}s")]
    RateLimitExceeded { retry_after: u64 },

    #[error("Validation error: {0}")]
    Validation(String),

    #[error("Internal server error")]
    Internal(#[source] Box<dyn std::error::Error + Send + Sync>),
}

impl ApiError {
    /// Convert to HTTP status code
    pub fn status_code(&self) -> u16 {
        match self {
            ApiError::NotFound { .. } => 404,
            ApiError::AuthFailed { .. } => 401,
            ApiError::RateLimitExceeded { .. } => 429,
            ApiError::Validation(_) => 400,
            ApiError::Database(_) | ApiError::Cache(_) | ApiError::Internal(_) => 500,
            ApiError::Serialization(_) => 500,
        }
    }

    /// Should this error be logged?
    pub fn should_log(&self) -> bool {
        matches!(self, ApiError::Database(_) | ApiError::Internal(_))
    }

    /// Client-safe error message
    pub fn client_message(&self) -> String {
        match self {
            ApiError::AuthFailed { reason } => reason.clone(),
            ApiError::NotFound { resource_type, .. } => {
                format!("{} not found", resource_type)
            }
            ApiError::Validation(msg) => msg.clone(),
            ApiError::RateLimitExceeded { retry_after } => {
                format!("Rate limit exceeded. Retry after {}s", retry_after)
            }
            // Don't expose internal errors to client
            _ => "Internal server error".to_string(),
        }
    }
}

// Usage in HTTP handler
use anyhow::Context;

async fn get_user_handler(user_id: i64) -> Result<User, ApiError> {
    // Database query - auto-converts sqlx::Error to ApiError
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(user_id)
        .fetch_optional(&pool)
        .await?
        .ok_or_else(|| ApiError::NotFound {
            resource_type: "User".to_string(),
            id: user_id.to_string(),
        })?;

    // Cache result - auto-converts RedisError to ApiError
    let serialized = serde_json::to_string(&user)?;
    redis_conn.set_ex(format!("user:{}", user_id), serialized, 3600).await?;

    Ok(user)
}

Real-World Example 2: CLI Application (Systems)

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

fn main() {
    if let Err(e) = run() {
        eprintln!("Error: {:?}", e);
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let config_path = std::env::args()
        .nth(1)
        .context("Missing config file argument. Usage: app <config.toml>")?;

    // Load config
    let config = load_config(&config_path)
        .context(format!("Failed to load config from {}", config_path))?;

    // Validate config
    ensure!(config.port > 0 && config.port < 65536, "Port must be between 1-65535");
    ensure!(!config.api_key.is_empty(), "API key cannot be empty");

    // Connect to database
    let mut conn = connect_database(&config.database_url)
        .context("Failed to connect to database")?;

    // Run migrations
    run_migrations(&mut conn)
        .context("Failed to run database migrations")?;

    // Start server
    start_server(&config)
        .context("Failed to start server")?;

    Ok(())
}

fn load_config(path: &str) -> Result<Config> {
    let contents = std::fs::read_to_string(path)?;
    let config: Config = toml::from_str(&contents)?;
    Ok(config)
}

fn connect_database(url: &str) -> Result<Connection> {
    Connection::connect(url)
        .context("Database connection failed")
}

fn run_migrations(conn: &mut Connection) -> Result<()> {
    for migration in MIGRATIONS {
        conn.execute(migration)
            .with_context(|| format!("Migration failed: {}", migration.name))?;
    }
    Ok(())
}

// Custom error for specific cases
fn validate_email(email: &str) -> Result<()> {
    if !email.contains('@') {
        bail!("Invalid email format: {}", email);
    }
    Ok(())
}

Real-World Example 3: Microservice Communication (Network)

use thiserror::Error;
use serde::{Serialize, Deserialize};

#[derive(Error, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "details")]
pub enum ServiceError {
    #[error("Upstream service '{service}' returned error: {message}")]
    UpstreamError {
        service: String,
        status_code: u16,
        message: String,
    },

    #[error("Service '{service}' timeout after {timeout_ms}ms")]
    Timeout {
        service: String,
        timeout_ms: u64,
    },

    #[error("Circuit breaker open for service '{service}'")]
    CircuitBreakerOpen {
        service: String,
        failure_count: usize,
    },

    #[error("Service '{service}' unavailable")]
    ServiceUnavailable {
        service: String,
        #[serde(skip)]
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync>>,
    },
}

// Service client with error composition
pub struct ServiceClient {
    http_client: reqwest::Client,
    service_url: String,
}

impl ServiceClient {
    pub async fn call_service(&self, payload: &Request) -> Result<Response, ServiceError> {
        let response = self.http_client
            .post(&self.service_url)
            .json(payload)
            .timeout(std::time::Duration::from_secs(5))
            .send()
            .await
            .map_err(|e| {
                if e.is_timeout() {
                    ServiceError::Timeout {
                        service: "user-service".to_string(),
                        timeout_ms: 5000,
                    }
                } else {
                    ServiceError::ServiceUnavailable {
                        service: "user-service".to_string(),
                        source: Some(Box::new(e)),
                    }
                }
            })?;

        if !response.status().is_success() {
            return Err(ServiceError::UpstreamError {
                service: "user-service".to_string(),
                status_code: response.status().as_u16(),
                message: response.text().await.unwrap_or_default(),
            });
        }

        Ok(response.json().await.unwrap())
    }
}

Error Conversion Patterns

Pattern 1: Manual From Implementation

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

// Now ? operator auto-converts
fn read_file() -> Result<String, MyError> {
    let contents = std::fs::read_to_string("file.txt")?;  // Auto-converts
    Ok(contents)
}

Pattern 2: thiserror #[from]

#[derive(Error, Debug)]
pub enum MyError {
    #[error("IO error")]
    Io(#[from] std::io::Error),  // Generates From impl automatically

    #[error("Parse error")]
    Parse(#[from] serde_json::Error),  // Another From impl
}

Pattern 3: map_err for Custom Context

fn read_config() -> Result<Config, MyError> {
    std::fs::read_to_string("config.toml")
        .map_err(|e| MyError::ConfigRead {
            path: "config.toml".into(),
            source: e,
        })?;

    // ...
}

anyhow Best Practices

Adding Context

use anyhow::Context;

// Add context to any error
result.context("High-level description")?;

// Add formatted context
result.with_context(|| format!("Processing user {}", user_id))?;

Downcast to Specific Types

use anyhow::Result;

fn handle_error(e: anyhow::Error) {
    // Check for specific error type
    if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
        eprintln!("IO error: {}", io_err);
    }
}

bail! and ensure!

use anyhow::{bail, ensure};

fn process(value: i32) -> Result<()> {
    // Early return with error
    if value < 0 {
        bail!("Value cannot be negative: {}", value);
    }

    // Assert-like error
    ensure!(value < 100, "Value too large: {}", value);

    Ok(())
}

Performance Considerations

| Approach | Allocation | Size | Performance |

|----------|-----------|------|-------------|

| Result | Stack | Small | Fastest |

| Result> | Heap | Pointer | Fast |

| anyhow::Result | Heap | Pointer + vtable | Fast |

Recommendation:
  • Libraries: Use thiserror (concrete types)
  • Applications: Use anyhow (flexibility)

⚠️ Anti-Patterns

⚠️ ❌ Mistake #1: Losing Error Information

// BAD: Discards original error
result.map_err(|_| MyError::Generic)?;

// GOOD: Preserve error chain
result.map_err(|e| MyError::Wrapped { source: e })?;

⚠️ ❌ Mistake #2: Too Many anyhow in Libraries

// BAD: Library with anyhow (loses type info)
pub fn lib_function() -> anyhow::Result<()> { ... }

// GOOD: Library with concrete error type
pub fn lib_function() -> Result<(), LibError> { ... }

⚠️ ❌ Mistake #3: Not Adding Context

// BAD: Unclear error origin
std::fs::read_to_string(path)?;

// GOOD: Clear context
std::fs::read_to_string(path)
    .with_context(|| format!("Failed to read config from {}", path))?;

Exercises

Exercise 1: Multi-Service Error

Build error type for app that uses database, cache, and external API.

Hints:
  • Use thiserror
  • Implement From for each service
  • Add context fields

Exercise 2: CLI with anyhow

Create CLI tool with config loading, file processing, and network calls.

Hints:
  • Use anyhow::Result
  • Add context with .context()
  • Use bail! for early returns

Exercise 3: Error Recovery

Implement retry logic with specific error handling.

Hints:
  • Pattern match on error variants
  • Retryable vs non-retryable
  • Exponential backoff

Further Reading

Real-World Usage

🦀 Tokio

Uses thiserror for structured errors in async runtime.

View on GitHub

🦀 ripgrep

CLI tool using anyhow for application errors.

View on GitHub

🦀 Actix Web

Web framework with thiserror for HTTP errors.

View on GitHub

🎮 Try it Yourself

🎮

Error Composition - Playground

Run this code in the official Rust Playground