2. Attachments

We want to be able to attach additional, typed values to any error. This allows us to provide contextual information for handling and debugging the error, such as backtraces, exit codes, locations in source files, request IDs, custom representations, formatting rules for different environments, etc.

The std Error trait supports exactly three attachments: the optional source() as well as the required Debug and Display textual representations.

An extensible solution would require storing and exposing APIs for additional attachments in our error types via a trait and/or wrapper types.

Problemo’s solution is remarkably trivial. Because we already have a wrapper type, Problem, we’ve simply included attachments as a vector of Box<dyn Any> for every cause in the chain. To add an attachment we just need to call with(). We also provide APIs that make it easy to find specific attachment types. Example:

use problemo::*;

tag_error!(OperatingSystemError, "operating system");
tag_error!(ParseError, "parse");

struct Location {
    row: usize,
    column: usize,
}

fn parse_file(path: &str) -> Result<(), Problem> {
    let content = std::fs::read_to_string(path).via(OperatingSystemError)?;
    ...
    // The as_problem() function is a shortcut constructor for Problem::from(error)
    // You can also use into_problem() for any std Error
    return Err(ParseError::as_problem().with(Location { row, column }));
    ...
}

fn main() {
    if let Err(problem) = parse_file("non-existing.txt") {
        println!("{}", problem);
    
        // Note that this will return the first Location in the causation chain;
        // Use attachments_of_type() if you want all of them 
        if let Some(location) = problem.attachment_of_type::<Location>() {
            println!("  at {}/{}", location.row, location.column);
        }
    }
}

Data vs. Metadata

Attachments are an error-handling super power as they allow us to cleanly separate the error “data” from its contextual “metadata”.

Consider that in our parser example above we might have various error types happening at a Location. Without the attachments feature we would probably have create something like a Locatable trait and implement it for all potential error types. And if it’s an external error type we would have to also wrap it (because of the orphan rules). It’s all quite painful boilerplate. We know because we’ve done it a lot. Indeed, this pain is the raison d’être for Problemo.

If you’ve used other error-handling libraries for Rust then you might have seen the term “context” being used not for metadata but for the actual error itself, i.e. the data. We think this seemingly small terminological choice has contributed to considerable misunderstanding and indeed to misuse. Even if you don’t want to use Problemo we hope that learning about it nudges you to think more clearly about this distinction.

It’s Probably Metadata

A best practice emerges: When we start defining an error struct, for every one of its fields we ask ourselves if it’s really contextual metadata. In less philosophical terms: Could this field potentially be relevant to other error types? If so, we make it an attachment type instead of a field. It then becomes a reusable building block for providing context to any error type.

If we follow this rule of thumb we find out that in practice many if not most of our error fields are better defined as attachments. This is why tag errors are so useful: When all the data is actually metadata that lives in attachments then we can get away with an empty struct for the actual error.

Indeed, this is so common that we provide the attachment!(), string_attachment!(), and static_string_attachment!() macros to make it easy to define newtypes for single-value attachments. Example:

use problemo::*;

tag_error!(NetworkError, "URL");
string_attachment!(UrlAttachment);

fn read_url(url: &str) -> Result<String, Problem> {
    let content = reqwest::blocking::get(url)
        .via(NetworkError)
        .with(UrlAttachment::new(url))?;
    ...
}

Loophole by Design

The causation chain is Box<dyn Error> while attachments are Box<dyn Any>. Doesn’t that create a loophole through which we can use non-Error types for everything and just “mask” them as tag errors?

Yes, and it’s entirely by design. Problemo’s headlining goal is “to improve the experience of working with Rust’s std Error trait”. Keep in mind that a Result has no constraints for its Err and it’s entirely viable to use types and traits that are incompatible with Error. Problem itself is not an Error. But we believe that it’s a good idea to encourage, support, and indeed require the use of Error in the causation chain because it allows for a bare-minimum, std-supported way of handling of errors even when their concrete types are unknown.

To go beyond the bare-minimum we would need to work with known, concrete types. As such it doesn’t really matter if we’re downcasting from a Box<dyn Error> or a Box<dyn Any>. In practice these two approaches look almost the same:

struct MyError { ... };

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

fn handle_by_error(problem: &Problem) {
    if let Some(my_error) = problem.error_of_type::<MyError>() {
        ...
    }
}

// Does not implement Error
struct MyCustomError { ... };

fn handle_by_attachment(problem: &Problem) {
    if let Some(my_custom_error) = problem.attachment_of_type::<MyCustomError>() {
        ...
    }
}

So, yes, we can use attachments for our “real” error types while relying on simple tags and glosses for the Error. It’s not cheating. It’s a way for us to provide a useful std facade.

For a different approach using supertraits see this example and compare it to this.

Debug Context

It can be useful to attach debug context to problems.

Problemo has support for attaching std Location via with_location() and backtraces via with_backtrace(). Both of these can be auto-attached to all problems by setting the environment variables PROBLEMO_LOCATION and/or PROBLEMO_BACKTRACE to anything other than “0”.

By default the backtrace is std Backtrace but by enabling the backtrace-external feature it will use (and re-export) the backtrace library instead, which provides access to stack frames.

See the example.

Location is in the std::panic module but it works when not panicking, too. However, for it to be truly useful we would need to be quite disciplined across our codebase. For one, we would need to add a #[track_caller] annotation to all functions in the call path that we want to skip, and that could have consequences in the case of debugging an actual panic. Also, closures count as functions but cannot currently be annotated (see issue) so they might have to be avoided. See how it can fit together in our extension trait example. All things considered, it might be better to rely on backtraces instead. Despite being possibly too verbose they will work without any of these caveats. Indeed, that verbosity can reveal important information for debugging.