thiserror, anyhow, or How I Handle Errors in Rust Apps
When I was reading the Rust book for the first time, the chapter regarding error handling was my favorite. For someone who lived for years with exceptions, it was an eye-opener.
In short, Rust distinguishes two types of errors: recoverable and unrecoverable. When a Rust program encounters a recoverable error, the compiler provides compile-time guarantees that such error will be handled on a call site. When an unrecoverable error happens, it means the program should exit immediately.
To raise an unrecoverable error, there is panic!
macro.
panic!("This call crashes the program");
When a panic happens, the program exits. Period.
For recoverable errors, there is the Result
type.
enum Result<T, E> {
Ok(T),
Err(E),
}
As you can see, the error type is generic. So the error might be of any type you like: string, struct, enum, or even unit.
When a function returns a Result
, the caller enforced to handle a possible error in order to access the data:
fn get_data() -> Result<Data, Error> {
// …
}
let result = get_data(); // we can’t use the data just yet, it must be unpacked first
match result {
Ok(data) => // now we have access to the data
Err(error) => // here, we are forced to handle the error
}
In addition to the Result
type, Rust provides a marker for types used as errors. This marker is implemented as the Error
trait. When some type implements this trait, it's guaranteed that such error has human-readable and debuggable representations. Also, it allows abstractions to relax the requirements by accepting any type that implements the Error
trait instead of forcing users into a specific type.
Ever since the Error
trait landed in Rust, the community settled on two crates: thiserror
and anyhow
.
thiserror
is a derive macro that simplifies the implementation of the Error
trait on user types and is supposed to be used mainly in abstractions.
anyhow
is a crate that provides an opaque anyhow::Error
type that implements the Error
trait. It's supposed to be used as a default error type in applications. The library also provides a couple of convenience macros, such as anyhow!
and bail!
, to construct anyhow::Error
.
When to use which? That's how I've been making my decisions:
- Use
thiserror
, when callers are interested in error details. E.g., if an error is an enum, a caller has to handle each error variant differently. - Use
anyhow
, when the caller doesn't care about the error details. E.g., it just propagates it to the logging pipeline.
I've been using both libraries, and my experience with anyhow
turned out not to be great. While it is convenient operating on a single type, error handling in apps becomes messy over time, which makes it hard to hold errors quality under control across the app.
There are two main issues I've been facing with anyhow
.
Let's say there is a complex processing pipeline that might fail in many different ways. After the first production failure, you realize that the error message misses an important identifier. And this id should be included in many other error messages across the pipeline. When using explicit type derived with thiserror
, it is easy to review and fix the types and then follow compiler errors to include missing data into the constructed errors. But with anyhow
, you have to dig into the implementation details to find and update all the required errors.
Another issue is that often, errors I was just propagating turned into errors I should be handling. Over time, I found myself constantly refactoring such errors from anyhow
to own types. So eventually, I ended up using thiserror
only.
Here are a few things that simplified my life with it.
Reducing the amount of handwriting
The main downside of thiserror
is that you have to write all the error messages by hand in the #[error()]
attribute.
#[derive(Error, Debug)]
enum ProcessingError {
#[error("Failed to parse X.\nError: {0}")]
FailedToParseX(SomeError),
#[error("Failed to update resource Y.\nId: {id}\nerror: {error}")]
FailedToUpdateResourceY { id: Uuid, error: SomeOtherError },
}
It is obvious that such errors can be auto-generated, and this code can be reduced to something like this:
#[Error]
enum ProcessingError {
FailedToParseX(SomeError),
FailedToUpdateResourceY { id: Uuid, error: SomeOtherError },
}
Then, the result would be:
return Err(ProcessingError::FailedToUpdateResourceY { id, error });
// ProcessingError::FailedToUpdateResourceY
// id: 123-456-789
// error: The failure details
This weekend, I finally had time to tackle such macro:
It piggybacks on the thiserror
crate by generating error messages to minimize manual work.
Leveraging From trait and ? operator
Rust provides very convinient ?
operator. When placed after a function call that returns a Result
(or an Option
), it implicitly unpacks the result: if it's Ok
, it gives the underlying data back to the caller, but if it's an Err
, it returns
the error from a function. These two implementations are equivalent:
fn fetch(url: &str) -> Result<Data, NetworkError> {
let data = match http.get(url) {
Ok(data) => data,
Err(error) => return Err(error),
};
Ok(data)
}
fn fetch(url: &str) -> Result<Data, NetworkError> {
let data = http.get(url)?;
Ok(data)
}
What can be propagated? Either the same error type that should be returned from a function or a type with an implementation of the From
trait, which allows to convert it to a type, that should be returned from this function.
enum ProcessingError {
FailedToGetData(NetworkError),
}
impl From<NetworkError> for ProcessingError {
fn from(error: NetworkError) -> Self {
ProcessingError::FailedToGetData(error)
}
}
fn process_data(url: &str) -> Result<Data, ProcessingError> {
let data: Data = http.get(url)?;
// ...
}
It's quite boilerplaty, but thiserror
can help with that. For example, when a function returns only one network error, you can use #[from]
attribute on the inner error, and thiserror
would generate From
implementation for you. The following implementation is equivalent to the previous one:
enum ProcessingError {
FailedToGetData(#[from] NetworkError),
}
fn process_data(url: &str) -> Result<Data, ProcessingError> {
let data: Data = http.get(url)?;
// ...
}
However, when multiple error variants contain NetworkError
, it's not possible to use #[from]
on both, so you have to be explicit when using ?
.
enum ProcessingError {
FailedToGetData(NetworkError),
FailedToUpdateData(NetworkError),
}
fn process_data(url: &str) -> Result<(), ProcessingError> {
let mut data: Data = http.get(url).map_err(ProcessingError::FailedToGetData)?;
// ...
http.post(url, data).map_err(ProcessingError::FailedToUpdateData)?;
Ok(())
}
Colocation
It feels natural when an error type is colocated with a function that returns it. Unfortunately, Rust doesn't allow defining structs/enums within implementations. So in the case of implementation with many methods, I had a situation when in a module structure, there were a bunch of error enums followed by the implementation with a bunch of methods. It was very inconvenient jumping back and forth between a type and a method.
#[Error]
enum ProcessingError { … }
#[Error]
enum UploadingError { … }
impl Pipeline {
fn process(&self) -> Result<Data, ProcessingError> { … }
fn upload(&self) -> Result<(), UploadingError> { … }
}
Luckily, Rust allows multiple impl
s of the same type, so it is possible to colocate error types and methods like this:
#[Error]
enum ProcessingError { … }
impl Pipeline {
fn process(&self) -> Result<Data, ProcessingError> { … }
}
#[Error]
enum UploadingError { … }
impl Pipeline {
fn upload(&self) -> Result<(), UploadingError> { … }
}
Not perfect, but better than when a type and a method are worlds apart.