Problemo
This library aims to improve the experience of working with Rust's std Error trait by allowing for deep causation chains and arbitrary attachments with the goal of making it easy and rewarding to return richly-typed errors for callers to inspect and handle.
Specifically, we've identified three features missing from std:
1. Causation Chains
Firstly, 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 way to express causation is to create an enum error type where each variant contains the source error. Libraries such as derive_more and thiserror make it easy enough to do so. 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 your sources are themselves enums, so the causation chain cannot be arbitrary.
Problemo's solution is to introduce a wrapper type for errors, Problem, which contains a causation chain of any error type (by relying on the familiar Box<dyn Error> mechanism). Simply call via() to add an error to the front of the chain. This mechanism does not replace the existing source() mechanism but instead complements it as we provide APIs that make it easy to iterate our chaining as well as recursively traversing source().
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 (just an empty struct), which can be chained in front of any error. This is so common that we provide the tag_error! macro to make it easy to create them. Example:
use *;
// The second optional argument is the Display message
// (defaults to the type name)
tag_error!;
If you prefer to group error types together then an enum of tags also works (using has() instead of has_type()). You can use derive_more or thiserror to define it easily. Example:
use ;
// Note that we need to implement PartialEq for has() to work
// This 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_type::<OperatingSystemError>()
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 simply callinto_error()when you need aProblemto implementError.
2. Attachments
Secondly, we want to be able to attach additional, typed values to any error. This allows us to provide additional context for error handling, such as backtraces, locations and spans in source files, and request IDs, as well as custom representations, formatting for different environments, etc.
The std Error trait supports exactly three attachments: the optional source() mentioned above as well as the required Debug and Display string representations.
An extensible solution would require storing and exposing APIs for additional attachments in your error types, possibly via a trait and/or wrapper types.
Our solution is remarkably trivial. Because we already have a wrapper type, Problem, we've simply included attachments in it as a vector of Box<dyn Any>. To add an attachment you just need to call with(my_attachment). We also provide APIs that make it easy to look for specific attachment types. Example:
use *;
tag_error!;
tag_error!;
Because attaching backtraces is very common we provide a with_backtrace() convenience method.
3. Error Accumulation
There are cases in which functions should be able to return more than one error. A classic example is parsing, as there might be multiple syntax and grammar errors in the input and callers (and users) would be interested in all of them. Another example is a function that distributes work among threads, where each thread could encounter different errors and all of them must be handled by the caller.
A common solution is to create a custom error type that internally stores multiple errors. Or, more simply, the error could just be a Vec of errors.
But our requirement goes beyond mere multiplicity. In some cases callers might care only that the function succeeds, in which case it would be more efficient to fail on the first error, a.k.a. "fail fast". We might also sometimes prefer to "stream" the errors rather than accumulating them in memory. For example, our parser might emit thousands of errors on a bad input. It would be more memory-efficient to print them out as they arrive.
Problemo's solution is the ProblemReceiver trait. If we want to accumulate the errors we can use the Problems type, which implements it by "swallowing" the errors into a vector. If we want to fail on the first error we can use the FailFast type, which implements it by simply returning the error. Problems also supports a list of optional "critical" error types: If it encounters one of these it fails fast instead of swallowing it.
Using this trait does involve some awkwardness. We believe it's worth it for the flexibility and opportunities for optimization.
The first challenge is that this is an inversion-of-control design, meaning that we have to provide the ProblemReceiver implementation. Most commonly this would be as a function argument.
Secondly, an error-accumulating function's Result might actually be Ok even if there are errors because they had all been swallowed by the receiver. The first consequence is that such a function needs to be able to return something with Ok. This could be a partial result, which can be useful in itself (e.g. we can show the user what we succeeded in parsing in spite of the errors). If that's impossible or irrelevant then we can just return an Option::None. The second consequence is that upon getting an Ok the caller will still need to check for accumulated errors. Problems has a check() function that does just that.
A useful advantage of inversion-of-control is that the caller owns the receiver. This means that it can be reused: You can call multiple functions with one Problems and then handle all the accumulated errors at once.
Proper use of this feature does require some discipline. To make it all a bit easier we provide a few friendly APIs. Example:
use *;
/// By our convention we'll put the receiver as the *last* argument
Some Other Features
Working with std Result
? on a Result will just work if the error is a std Error type (because Problem implements From<Error>). In effect this starts a causation chain.
We furthermore provide an easy-to-use extension trait for std Result so you can do via() and with() on it directly without having to map_err(). We've used it in some of the examples above.
Just Strings
Do you only care about error messages and not error types? Then you can do:
return Err;
Fancier version:
return Err;
Behind the scenes your string will be wrapped in MessageError, which is just a string newtype.
Or be a bit less lazy and use our message_error!() macro to create more specific message error types. That way callers would be able to differentiate between them. It would take just a bit more work from you. You can do it! We believe in you! Example:
use *;
// The second optional argument is a prefix for Display
message_error!;
Unmovable Errors
Is your error unmovable? If so, it cannot be captured in a Problem. This is famously the case with PoisonError, which you can get from locking a Mutex. We can, however, capture such an error's Display representation, which we enable with into_message_problem() (which uses the MessageError type mentioned above) as well as into_concurrency_problem() (which uses ConcurrencyError).
Alternatively you can use map_into_problem() and provide your own conversion function. As it simplest it can conserve the Display, but it can also have fields in which you conserve additional information. The point is to translate the error into a movable one.
FAQ
Why doesn't Problem wrap a concrete error type? Wouldn't it better to allow the compiler to check for correct usage?
Our experience in large projects with error-handling libraries that require typed errors has led us to believe that it's a cumbersome and ultimately pointless requirement, especially for high-level functions that might have long causation chains. That's where the meat is. In practice just having the compiler test that the topmost "tag" is correct guarantees very little if you're not actually handling that error. It's just clutter.
Why doesn't Problemo come with macros such as bail!()?
Most error-handling libraries have macros that optimize the creation and returning of errors so that it would take the smallest number of keyboard strokes. Problemo instead prefers verbosity, clarity, and debuggability by suggesting explicit function calls.
We use macros only when functions can't work. Generally speaking our code is deliberately non-magical and should be easy for any Rust programmer to understand. Error handling is foundational and should not be a mysterious black box.
You are of course free to create your own macros for Problemo but we don't want to encourage their use in the published API.
I like Problemo but it's missing my favorite feature!
That's not a question! Anyway, we are happy to hear your suggestions. Please be nice about it and do keep in mind that we want to keep Problemo lean, simple, and focused on the essentials. If the feature is something that can be built on top of Problemo, perhaps as a supplementary library, then that will likely be the preferred route.
Spanish?
Actually it's Spanglish. And even more actually it's Esperanto. Mi havas naŭdek naŭ problemojn, sed hundino ne estas unu.
"AI"?
Please, no.
Popular Alternatives
-
error-stack, like Problemo, supports chaining and attachments. It does, however, require you to provide a concrete error type for your returns, which it calls the "context". It supports returning groups of errors as long as they are of that same "context" type. It also features backtraces and pretty printing.
-
rootcause also supports chaining and attachments. It supports both type-less wrappers like Problemo as well as error-stack-style typed errors. It comes with a customizable error formatter and many other features. Its scope is broad and its relatively complex.
-
anyhow is a one-trick pony. It's essentially a spruced-up
Box<dyn Error>that is optimized for reduced memory usage. It does add support for two attachment types: a backtrace, which is handled implicitly and automatically, and what is calls a "context", which is an implementation of stdDisplay. Anyhow does not support chaining or other attachment types. -
SNAFU works similarly to Anyhow in practice but takes a different design approach by introducing its own set of traits as a replacement for std
Errorwhile also allowing for compatibility with it. This allows you to build custom, rich error types on top of SNAFU. Otherwise it also supports backtraces and a "context" attachment. -
eyre is similar to (and compatible with) Anyhow but also supports chaining and pretty printing.
License
Like much of the Rust ecosystem, licensed under your choice of either of
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.