Error Context

Maintaining error chains and context

advanced
contexterror-chains
🎮 Interactive Playground

Why Error Context Matters

When errors occur deep in the call stack, you need context to understand:

  • Where did it originate?
  • What operation was being performed?
  • What data was involved?
Problem without context:
Error: File not found
With context:
Error: Failed to load user configuration
  Caused by: Failed to read config file
  Caused by: File not found: /etc/app/config.toml

Pattern 1: Manual Context with source()

use std::error::Error;
use std::fmt;

#[derive(Debug)]
pub struct AppError {
    context: String,
    source: Option<Box<dyn Error + Send + Sync>>,
}

impl AppError {
    pub fn new(context: impl Into<String>) -> Self {
        Self {
            context: context.into(),
            source: None,
        }
    }

    pub fn with_source(context: impl Into<String>, source: impl Error + Send + Sync + 'static) -> Self {
        Self {
            context: context.into(),
            source: Some(Box::new(source)),
        }
    }
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.context)
    }
}

impl Error for AppError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.source.as_ref().map(|e| e.as_ref() as &(dyn Error + 'static))
    }
}

// Usage
fn load_user_data(user_id: u64) -> Result<User, AppError> {
    let path = format!("/data/users/{}.json", user_id);

    std::fs::read_to_string(&path)
        .map_err(|e| AppError::with_source(
            format!("Failed to read user file: {}", path),
            e
        ))?;

    // ...
    Ok(user)
}

Pattern 2: anyhow::Context

use anyhow::{Context, Result};

fn process_order(order_id: u64) -> Result<()> {
    let order_file = format!("orders/{}.json", order_id);

    let contents = std::fs::read_to_string(&order_file)
        .with_context(|| format!("Failed to read order file: {}", order_file))?;

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

    validate_order(&order)
        .with_context(|| format!("Order {} validation failed", order_id))?;

    process_payment(&order)
        .context("Payment processing failed")?;

    Ok(())
}

// Error output:
// Error: Payment processing failed
//   Caused by: Failed to charge card
//   Caused by: Network timeout

Real-World Example 1: Database Operations (Systems)

use anyhow::{Context, Result};

async fn fetch_user_with_posts(user_id: i64) -> Result<UserWithPosts> {
    // Context for each operation
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(user_id)
        .fetch_one(&pool)
        .await
        .with_context(|| format!("Failed to fetch user {}", user_id))?;

    let posts = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE user_id = $1")
        .bind(user_id)
        .fetch_all(&pool)
        .await
        .with_context(|| format!("Failed to fetch posts for user {}", user_id))?;

    // Aggregate
    Ok(UserWithPosts { user, posts })
}

// Error chain example:
// Error: Failed to fetch posts for user 123
//   Caused by: Database query failed
//   Caused by: Connection timeout

Real-World Example 2: Config Loading Chain (Systems)

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

fn load_and_validate_config() -> Result<Config> {
    // Try multiple locations
    let config_content = try_load_from_env()
        .or_else(|_| try_load_from_file("./config.toml"))
        .or_else(|_| try_load_from_file("/etc/app/config.toml"))
        .context("Failed to load config from any location")?;

    let mut config: Config = toml::from_str(&config_content)
        .context("Failed to parse TOML config")?;

    // Validate with context
    if config.port == 0 {
        bail!("Invalid port number: 0. Port must be between 1-65535");
    }

    if config.workers == 0 {
        bail!("Invalid worker count: 0. Must have at least 1 worker");
    }

    // Expand environment variables
    expand_env_vars(&mut config)
        .context("Failed to expand environment variables in config")?;

    Ok(config)
}

fn try_load_from_file(path: &str) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config file: {}", path))
}

Real-World Example 3: Multi-Service API Call (Network)

use anyhow::{Context, Result};

async fn aggregate_user_data(user_id: u64) -> Result<AggregatedData> {
    // Call multiple services with context
    let profile = fetch_user_profile(user_id)
        .await
        .context("Failed to fetch user profile from user-service")?;

    let orders = fetch_user_orders(user_id)
        .await
        .context("Failed to fetch orders from order-service")?;

    let recommendations = fetch_recommendations(user_id)
        .await
        .context("Failed to fetch recommendations from ml-service")?;

    Ok(AggregatedData {
        profile,
        orders,
        recommendations,
    })
}

async fn fetch_user_profile(user_id: u64) -> Result<Profile> {
    let response = http_client
        .get(&format!("http://user-service/users/{}", user_id))
        .send()
        .await
        .with_context(|| format!("HTTP request failed for user {}", user_id))?;

    if !response.status().is_success() {
        bail!("User service returned status {}", response.status());
    }

    response.json()
        .await
        .context("Failed to parse profile JSON")
}

Pattern: Context Extension Trait

use anyhow::{Context as _, Result};

trait ResultExt<T> {
    fn with_field_context(self, field: &str) -> Result<T>;
    fn with_path_context(self, path: &std::path::Path) -> Result<T>;
}

impl<T, E: std::error::Error + Send + Sync + 'static> ResultExt<T> for Result<T, E> {
    fn with_field_context(self, field: &str) -> Result<T> {
        self.with_context(|| format!("Invalid value for field '{}'", field))
    }

    fn with_path_context(self, path: &std::path::Path) -> Result<T> {
        self.with_context(|| format!("Error processing file: {}", path.display()))
    }
}

// Usage
fn parse_config_field(value: &str) -> Result<u32> {
    value.parse().with_field_context("port")
}

Printing Error Chains

fn print_error_chain(mut err: &dyn std::error::Error) {
    eprintln!("Error: {}", err);

    while let Some(source) = err.source() {
        eprintln!("  Caused by: {}", source);
        err = source;
    }
}

// With anyhow
fn print_anyhow_chain(err: &anyhow::Error) {
    eprintln!("Error: {:?}", err);  // Debug shows full chain

    // Or manually
    for (i, cause) in err.chain().enumerate() {
        if i == 0 {
            eprintln!("Error: {}", cause);
        } else {
            eprintln!("  Caused by: {}", cause);
        }
    }
}

Context Best Practices

✅ Do: Add Context at Each Layer

// Good: Each layer adds its own context
fn process_payment() -> Result<()> {
    charge_card()
        .context("Failed to charge credit card")?;

    update_database()
        .context("Failed to update payment records")?;

    send_receipt()
        .context("Failed to send receipt email")?;

    Ok(())
}

✅ Do: Include Relevant Data

// Good: Context includes IDs, paths, etc.
user_service.get_user(user_id)
    .with_context(|| format!("Failed to fetch user with ID {}", user_id))?;

❌ Don't: Generic Context

// Bad: Doesn't help debugging
operation().context("An error occurred")?;

// Good: Specific context
operation().context("Failed to initialize database connection pool")?;

❌ Don't: Duplicate Information

// Bad: Repeats error message
std::fs::read_to_string(path)
    .context(format!("No such file or directory: {}", path))?;

// Good: Adds new context
std::fs::read_to_string(path)
    .with_context(|| format!("Failed to load config from {}", path))?;

Structured Error Context

#[derive(Debug)]
pub struct ErrorContext {
    operation: String,
    user_id: Option<u64>,
    request_id: Option<String>,
    timestamp: std::time::SystemTime,
}

impl ErrorContext {
    pub fn new(operation: impl Into<String>) -> Self {
        Self {
            operation: operation.into(),
            user_id: None,
            request_id: None,
            timestamp: std::time::SystemTime::now(),
        }
    }

    pub fn with_user(mut self, user_id: u64) -> Self {
        self.user_id = Some(user_id);
        self
    }

    pub fn with_request_id(mut self, request_id: String) -> Self {
        self.request_id = Some(request_id);
        self
    }
}

// Usage
fn process_request(user_id: u64, request_id: String) -> Result<()> {
    let ctx = ErrorContext::new("process_user_request")
        .with_user(user_id)
        .with_request_id(request_id);

    perform_operation()
        .with_context(|| format!("{:?}", ctx))?;

    Ok(())
}

Exercises

Exercise 1: API Gateway Error Context

Build error handling for API gateway that calls 3+ microservices.

Hints:
  • Service name in context
  • Request IDs
  • Timing information

Exercise 2: File Processing Pipeline

Create pipeline that reads, transforms, validates, writes - with context at each stage.

Hints:
  • File paths in context
  • Line numbers for parse errors
  • Stage names

Exercise 3: Transaction Context

Implement database transaction with context for each operation.

Hints:
  • Transaction ID
  • Table names
  • Query snippets

Further Reading

Real-World Usage

🦀 Cargo

Uses context for build error reporting.

View on GitHub

🦀 Diesel

ORM with detailed query error context.

View on GitHub

🦀 sqlx

Database library with query context in errors.

View on GitHub

🎮 Try it Yourself

🎮

Error Context - Playground

Run this code in the official Rust Playground