Error Handling

Error Context

Error context is high-level operational information—such as a file path, configuration key, or network endpoint—attached to a low-level error as it propagates up the call stack.

Why Low-Level Errors Need Context

A low-level function such as std::fs::read_to_string(path) or str::parse::<u16>() only knows what failed at the system or syntax level (No such file or directory or invalid digit found in string), not which file or setting caused the failure:

Without Context:
  Error: No such file or directory (os error 2)

With Context:
  Error: failed to read TLS certificate at "/etc/app/cert.pem"
    Caused by: No such file or directory (os error 2)

Attaching Context with .map_err()

Wrapping a lower-level error inside a context-carrying struct or enum variant via .map_err(|e| ...) preserves both the high-level operation description and the underlying cause:

use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
struct FieldParseError {
    field_name: &'static str,
    raw_value: String,
    source: ParseIntError,
}

impl fmt::Display for FieldParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "failed to parse field '{}' from {:?}",
            self.field_name, self.raw_value
        )
    }
}

impl Error for FieldParseError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

fn parse_field(field_name: &'static str, raw: &str) -> Result<u32, FieldParseError> {
    raw.trim().parse::<u32>().map_err(|err| FieldParseError {
        field_name,
        raw_value: raw.to_string(),
        source: err,
    })
}

fn main() {
    if let Err(e) = parse_field("max_connections", "500x") {
        println!("Error: {e}");
        if let Some(cause) = e.source() {
            println!("  Caused by: {cause}");
        }
    }
}
Error: failed to parse field 'max_connections' from "500x"
  Caused by: invalid digit found in string

Reusable Context Extension Trait (with_context)

To avoid writing repetitive .map_err(...) boilerplate at every call site, you can define an extension trait on Result<T, E> that lazily evaluates a context message closure only when an Err occurs:

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

#[derive(Debug)]
pub struct ContextError {
    context: String,
    source: Box<dyn Error + 'static>,
}

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

impl Error for ContextError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&*self.source)
    }
}

pub trait ResultContextExt<T> {
    fn with_context<C, F>(self, f: F) -> Result<T, ContextError>
    where
        C: fmt::Display,
        F: FnOnce() -> C;
}

impl<T, E> ResultContextExt<T> for Result<T, E>
where
    E: Error + 'static,
{
    fn with_context<C, F>(self, f: F) -> Result<T, ContextError>
    where
        C: fmt::Display,
        F: FnOnce() -> C,
    {
        self.map_err(|err| ContextError {
            context: f().to_string(),
            source: Box::new(err),
        })
    }
}

fn parse_timeout_ms(env_name: &str, raw: &str) -> Result<u64, ContextError> {
    let seconds: u64 = raw
        .parse()
        .with_context(|| format!("invalid timeout in environment variable {env_name}"))?;
    Ok(seconds * 1000)
}

fn main() {
    match parse_timeout_ms("DB_TIMEOUT_SECS", "thirty") {
        Ok(ms) => println!("Timeout: {ms} ms"),
        Err(err) => {
            println!("Error: {err}");
            let mut cur = err.source();
            while let Some(cause) = cur {
                println!("  Caused by: {cause}");
                cur = cause.source();
            }
        }
    }
}
Error: invalid timeout in environment variable DB_TIMEOUT_SECS
  Caused by: invalid digit found in string