1. Causation Chains
We want to be able to set any error as the cause of any other error. This allows us to build causation chains that we can inspect and handle as needed.
The std Error trait does have a source() function, but the trait does not provide a way for setting the source. Indeed, it is an optional feature.
A common workaround for this limitation is to create an enum error type where each variant contains the source error. Libraries such as derive_more and thiserror help by setting up the plumbing. Unfortunately this solution requires us to define variants for all the error types that can be our source. This bookkeeping become tedious as new error types must be added when functionality grows, while old, no-longer-used error types linger on and clutter the codebase. And since the same lower-level errors crop up again and again these enums have a lot of duplication. The biggest problem is that further nesting is possible only if our sources are themselves enums, leading to a proliferation of these enums.
Problemo’s solution is to introduce a wrapper type for errors, Problem, which is simply a dynamic causation chain relying on the familiar Box<dyn Error> mechanism. Call via() on a Problem to add an error to the top of the chain. This mechanism does not replace the std source() but instead complements it as we provide APIs that make it easy to iterate our chaining as well as recursively traversing source().
Example of traversing the “tree”:
fn read_the_file(path: &str) -> Result<String, Problem> {
Ok(std::fs::read_to_string(path)?)
}
fn main() {
if let Err(problem) = read_the_file("non-existing.txt") {
// We can iterate the problem directly (the causation chain "trunk")
for cause in &problem {
println!("cause branch:");
// For each cause we can iterate the "branch" of Error::source() calls
for error in cause.iter_sources() {
println!("• {}", error);
}
}
}
}
Note that
Problemdoes not itself implement the stdErrortrait, though it does implementDebugandDisplay. This is due to a current limitation in Rust’s type system that may have a workaround in the future. Until then we can simply callinto_error()when we need aProblemto implementError.
Working with std Result
? on a Result will Just Work™ if the error is a std Error type. This is because Problem implements From<Error>. In effect such an ? is the start of a causation chain.
That said, we want to put our best foot forward. Problemo comes with an extension trait for std Result, so we can insert a via() and/or a with() (see attachments) before the ?. At the very least we can add a quick via(common::LowLevelError).
The functions also have lazy versions, such as map_via() and map_with(), that will generate values only when there is an error.
Memory Usage
Problemo relies on thin-vec by default, allowing Result<_, Problem> to take as little memory as possible when there is no error. If this is undesirable we can use default-features = false in our Cargo.toml to switch to std collection types and remove the thin-vec dependency.
Tag Errors
Because it’s so easy to chain errors with via() an elegant pattern emerges. Instead of managing one big enum we can create simple, reusable “tag” error types (they are just an empty struct), which can be chained on top of any error. This is so common that we provide the tag_error!() macro to make it easy to create them. Example:
use problemo::*;
// The second optional argument is the Display representation
// (defaults to the type name)
tag_error!(OperatingSystemError, "operating system");
fn read_the_file(path: &str) -> Result<String, Problem> {
std::fs::read_to_string(path).via(OperatingSystemError)
}
fn main() {
if let Err(problem) = read_the_file("non-existing.txt") {
if problem.has_error_type::<OperatingSystemError>() {
println!("Your computer is broken!");
} else {
println!("Could not read. Try again?");
}
}
}
Problemo comes with commonly used tag error types in its common module.
Handling Errors
If we care about the error(s) that caused the problem then we can traverse the causation chain with cause_with_error_type(), under(), and iter_under():
if let Some(cause) = problem.cause_with_error_type::<OperatingSystemError>() {
println!("Your computer is broken!");
for cause in cause.iter_under() {
println!(" because: {}", cause.error);
}
}
A consequence of how easy it is to add a via() and then iterate the causation chain is that tag errors can be used to mark barriers between segments of the chain. For example, if we want to print everything that’s not low-level, we can just stop there:
for cause in problem.iter_causes_until_error_type::<LowLevelError>() {
println!("• {}", cause.error);
}
Grouping Error Types
Tag errors are unrelated to each other in the type system. However, sometimes it can be useful to group them together as variants of a basic type. In other words, an enum.
We can use derive_more to easily define such an enum and then use Problemo’s has_error() and cause_with_error() instead of has_error_type() and cause_with_error_type(). Example:
use {derive_more::*, problemo::*};
// Note that we need to implement PartialEq for has_error() to work
#[derive(Debug, Display, Error, PartialEq)]
enum OperatingSystemError {
#[display("I/O")]
IO,
#[display("network")]
Network,
}
// The above is similar to just using:
// tag_error!(IoError);
// tag_error!(NetworkError);
//
// The advantage of grouping them together in an enum is that we can do:
// has_error_type::<OperatingSystemError>()
fn read_the_file(path: &str) -> Result<String, Problem> {
// We're using via() twice here just to show that we can
std::fs::read_to_string(path)
.via(OperatingSystemError::IO)
.via(common::LowLevelError)
}
fn main() {
if let Err(problem) = read_the_file("non-existing.txt") {
if problem.has_error(&OperatingSystemError::IO) {
println!("Your computer is broken!");
}
...
}
}
As it turns out, the advantage of using an enum over individual error types is rather minor. Because Problem handles causation chaining for us the enum doesn’t have to fulfil that role. As such, we don’t end up reaching for this pattern very often.
Gloss Errors
An enum is one way to express variations of a type. But what if the difference between our variations is merely cosmetic and doesn’t matter for actual error handling?
For this simpler use case we provide the gloss_error!() and static_gloss_error!() macros, which are similar to tag_error!() but with the option of allowing us to change their Display. They are essentially string newtypes. Example:
use problemo::*;
// The second optional argument is a prefix for Display
static_gloss_error!(InvalidPathError, "invalid path");
fn read_the_file(path: &str) -> Result<String, Problem> {
if path.is_empty() {
return Err(InvalidPathError::as_problem("empty"));
} else if !path.starts_with('/') {
return Err(InvalidPathError::as_problem("not absolute"));
}
...
}
Although we could compare the inner strings of gloss errors in order to differentiate them, it’s not recommended. String comparison is much less efficient than type matching. If we find ourselves caring about the gloss’s value then it’s a poor fit for our use case. We should be using tag errors or an
enuminstead. Or… an attachment.
Problemo comes with a bunch of commonly used gloss error types in its common module. One of them is simply called GlossError. We even provide a convenience function to create it:
use problemo::{*, common::*};
// Disgustingly easy
return Err("I failed".gloss());
// Easy and *fancy*
return Err(format!("{} failed", subject).gloss());
That’s both clear and kinda cute, isn’t it? But it’s also a bit lazy. gloss() will work in a pinch but we still recommend using one of the common gloss error types or defining our own so that callers can better differentiate them. Examples:
// Like this:
return Err(InvalidError::as_problem("wrong type"));
// On a Result:
fn read_file(path: &str) -> Result<String, Problem> {
std::fs::read_to_string(path).into_gloss::<InvalidError>()
}
It’s really not that much more verbose than a plain gloss(), is it?
The Locality Caveat
Problemo’s requirement for a causation chain implies historical continuity between the links in the chain. As we iterate we are also moving back in time. We are also moving in “space”. Thanks, Einstein! For now, Problemo does not support faster-than-light travel, which would allow causes to precede effects. Nevertheless, the notion that our errors “travel” to get to us is a common metaphor. Sometimes this is described as errors “bubbling up” from “lower levels” in the application.
In Rust terms, this expectation implies movability in memory as well as portability across thread boundaries. Specifically, the Box<dyn Error> approach requires 'static, and Problemo adds a requirement for Send and Sync, too.
But not all errors are expected to travel. Specifically, they may contain lifetime-bounded references and/or data that is not Send and Sync. Such errors must be handled “locally” within the lifetime and/or thread in which they are created. As such they cannot be captured into a Problem’s causation chain.
That said, we might still want to include a record of such an error in the chain; not the error itself but rather a representation of it. If it’s enough to just capture its Display, then into_thread_problem(), into_gloss(), and even gloss() would all do the trick. Otherwise, we can use the standard map_err() function to provide our own conversion, in which we can make use of a new error type and/or add attachments. Here’s a simple example:
use {problemo::{*, common::*}, std::sync::*};
fn get_string(string: &Mutex<String>) -> Result<String, Problem> {
// We are glossing the PoisonError (capturing its Display)
string
.lock()
.into_thread_problem()
.map(|string| string.clone())
}