Skip to Content
Course content

161: Error Context and Chaining

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've spent a lot of time talking about Result and how to propagate errors using the ? operator. But here is the reality of production software: a raw std::io::Error that says "No such file or directory" is almost useless when your application is trying to load twelve different configuration files across three different directories. You need to know which file failed and why it mattered to the program.

In this lesson, I want to show you how to add "context" to your errors. While you can do this with vanilla Rust using map_err, in the real world, most of us reach for the anyhow crate for application-level error handling because it makes chaining context a breeze.

The problem with "naked" errors

Let's start with a simple scenario. I'm building a small tool that reads a user's settings from a file. Here is how I'd write it if I were being lazy:

use std::fs;
use anyhow::Result;

fn load_settings() -> Result<String> {
    let content = fs::read_to_string("settings.toml")?;
    Ok(content)
}

fn main() {
    if let Err(e) = load_settings() {
        eprintln!("Error: {}", e);
    }
}

If settings.toml is missing, the output is: Error: No such file or directory (os error 2). It's accurate, sure, but it's cold. If this were a massive system, I'd be hunting through the source code just to find which function triggered that IO error.

Wrapping errors in meaning

This is where anyhow::Context comes in. Instead of just passing the error up the stack with ?, we can attach a human-readable string to it. This doesn't replace the original error; it wraps it, creating a chain.

use std::fs;
use anyhow::{Context, Result};

fn load_settings() -> Result<String> {
    let path = "settings.toml";
    fs::read_to_string(path)
        .with_context(|| format!("Failed to read settings file at {}", path))?
}

I used with_context here instead of context because it takes a closure. This is a performance win—the string formatting only happens if an error actually occurs, rather than every time the function is called. Now, if the file is missing, we get: Error: Failed to read settings file at settings.toml: No such file or directory (os error 2).

Where I usually trip up: The "Context Gap"

I'll be honest: when I first started using this pattern, I'd often add context at the very bottom of the call stack and assume that was enough. But as the app grows, you realize you need context at every layer of the operation to truly trace the failure.

Look at this mistake I made while extending the example. I added a helper function to validate the settings, but I forgot to add context to the call site in the main coordinator function:

fn validate_settings(content: &str) -> Result<()> {
    if content.is_empty() {
        anyhow::bail!("Settings file is empty");
    }
    Ok(())
}

fn initialize_app() -> Result<()> {
    // I added context here...
    let settings = load_settings().context("App initialization failed")?; 
    
    // ...but I forgot it here!
    validate_settings(&settings)?; 
    
    Ok(())
}

If validate_settings fails, the error message is just "Settings file is empty". The "App initialization failed" context is completely bypassed because it was only attached to the load_settings call. I've created a "context gap."

Creating a complete diagnostic chain

To fix this, I need to treat error context like a breadcrumb trail. Every time I call a function that could fail, I should ask: "If this fails, what additional information does the user need to know to fix it?"

Here is the refined version. Notice how the error flows from the specific (the file path) to the general (the app initialization):

fn initialize_app() -> Result<()> {
    let settings = load_settings()
        .context("Could not load initial configuration")?;

    validate_settings(&settings)
        .context("Configuration was loaded but contained invalid data")?;

    Ok(())
}

Now, if the file is empty, the output becomes a beautiful, hierarchical trace:
Error: Configuration was loaded but contained invalid data: Settings file is empty

By chaining these, you've turned a cryptic crash into a diagnostic report. You aren't just telling the user that it failed; you're telling them where in the logic the failure happened.




📋 Practical Task

Build a Multi-Stage Asset Loader with Error Chaining

Create a program that simulates loading a game's assets. You need to implement a chain of functions that can fail, ensuring that every failure provides a clear "breadcrumb" of context using anyhow.

  • Create a function load_map_file() that attempts to read a file named map.txt. Give it context including the filename.
  • Create a function parse_map_data() that takes the string from the map file and fails if the string doesn't contain the word "START". Give it context explaining that the map format is invalid.
  • Create a coordinator function setup_game_world() that calls both of the above. Wrap each call in its own context (e.g., "Failed to initialize game world").
  • In main, call setup_game_world() and print the resulting error using {:?} (the debug formatter) to see the full chain of causes.

Goal: When map.txt is missing or invalid, your output should show at least three layers of context: the high-level "game world" failure, the mid-level "map loading/parsing" failure, and the low-level IO or validation error.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.