thiserror, anyhow, or How I Handle Errors in Rust Apps

16 Nov, 2022 · #dev#rust

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.

rust
panic!("This call crashes the program");

When a panic happens, the program exits. Period.

For recoverable errors, there is the Result type.

rust
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:

rust
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.

rust
#[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:

rust
#[Error]
enum ProcessingError {
FailedToParseX(SomeError),
FailedToUpdateResourceY { id: Uuid, error: SomeOtherError },
}

Then, the result would be:

rust
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:

rust
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.

rust
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:

rust
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 ?.

rust
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.

rust
#[Error]
enum ProcessingError {}
#[Error]
enum UploadingError {}
impl Pipeline {
fn process(&self) -> Result<Data, ProcessingError> {}
fn upload(&self) -> Result<(), UploadingError> {}
}

Luckily, Rust allows multiple impls of the same type, so it is possible to colocate error types and methods like this:

rust
#[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.