Errors

Rust's try operator (?) and support for sum types (e.g., Result which can be either Ok or Err) make for powerful and easy-to-reason-about error handling. Unfortunately as of this writing in late 2020 there is no consensus solution for representing and reporting errors in crates. The existing std::result::Result and std::error::Error types provide the bare bones for representing successful and failed operations, but they're not adequate on their own.

In first year or so of Elastio, we used the snafu crate, which is an opinionated implementation of error handling in Rust that seemed to work well. Many of our crates still use Snafu today, because there's no reason to migrate. However recently momentum has been building behind two crates which together are fast becoming the de facto Right Way(tm) for doing things: anyhow and thiserror.

anyhow provides a universal Error type which can wrap any std::error::Error implementation and provide some useful operations. It's the equivalent of throws Exception in Java. We use anyhow when building CLIs that need to be able to handle various kinds of errors from different crates and have no need to wrap those errors in a specific error type. In some cases we also use anyhow when writing tests, when the alternative would be Box<dyn Error> which is nasty.

thiserror is used at Elastio when a library crate needs to expose an error enum as part of it's API. Where historically we've used snafu, newer crates (and any crates created in the future) use thiserror to build a CrateNameError variant.

This chapter describes the best practices we've evolved with these crates, and should be followed unless there's a good reason to do something different.

Official Docs

Most of the details of how to use anyhow and thiserror are covered in their respective docs. None of that will be repeated here, so before reading the rest of this chapter make sure you've reviewed the official docs for both crates and have a good understanding of how they work in general (and, in particular, how anyhow is different than thiserror and under what circumstances one should use one versus the other).

Legacy Snafu

As noted above, many of our biggest and most important crates were written before thiserror was clearly the way forward for library error reporting. Those crates use snafu instead. There's nothing wrong with snafu, and it's similar in design to thiserror in many ways. Those crates that use snafu will continue to do so, and if you find yourself needing to add or modify the error variants in those crates, you too must use snafu. Porting existing error handling to thiserror without a compelling reason is not a good use of engineering time.

Having said that, starting a new crate in 2020 or beyond and using snafu to build the error type is also not a good use of engineering time.

Error variant naming

Each variant of the error enum is obviously an error, because it's a variant of the error enum. Thus, the word Error should not appear in the name of the error enum as it's redundant. So IoError is a bad name for an error; Io is good.

Error messages

thiserror

When defining an error type with thiserror, it's easy to define what the error message should be for an error:

#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("IO error")]
    Io {
        source: std::io::Error
    }
}
}

You might be tempted to try to make another error type's message part of your error, like this:

#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    // WRONG DON'T DO THIS
    #[error("IO error: {source}")]
    Io {
        source: std::io::Error
    }
}
}

You might even find this pattern in our code still. However the latest best practice is to report the error type which caused a particular error by returning it in the std::error::Error::source() function, which for thiserror means a field named source or marked with the #[source] or #[from] attributes. Why?

Because it's often not at all useful to include the error message of an inner error. Maybe that error is itself just a higher-level error type, and the real cause of the error is nested three or more errors deep. Or maybe you need to know the details of all of the errors in the chain from root cause on up to your error type in order to understand what really happened.

Therefore, instead of forcing the nested error's message into your error message, you should rely on crates like anyhow (or color-eyre) to pretty-print error types at the point where they are communicated to users (printed to the console in the case of a CLI, or written to the log in the case of a server).

anyhow

When reporting errors with anyhow, the principle is the same but the mechanics are slightly different. Since anyhow::Error is a universal container for any error type, you do not use anyhow to define strongly typed errors. Instead you wrap arbitrary error types in anyhow. But sometimes you have a situation where you have an error e, and you want to report it as an anyhow::Error but with some additional context to help clarify the error. This is the wrong way:

#![allow(unused)]

fn main() {
use anyhow::anyhow;

// DO NOT DO THIS
let e = std::io::Error::last_os_error();
// Assume `e` contains an error you want to report
anyhow!(format!("Got an error while trying to frobulate the gonkolator: {}", e));
}

This has the same problem as the thiserror example above. You're losing all of the information in e other than it's message. Maybe it had its own source with valuable context, or a backtrace that would have clarified where this happened. Instead you should use anyhow to wrap the error in some context:

#![allow(unused)]

fn main() {
use anyhow::anyhow;

// This is the right way
let e = std::io::Error::last_os_error();
// Assume `e` contains an error you want to report
anyhow::Error::new(e).context("Got an error while trying to frobulate the gonkolator");
}

Using anyhow context or with_context

The above example uses context on an anyhow::Error. anyhow also has a Context trait which adds context and with_context to arbitrary Result types, to make it easier to wrap possible errors in context information.

Be advised that in this case you should avoid allocating strings when calling context. For example:

use anyhow::Context;

fn get_username() -> String {
    // ...
  "foo".to_owned()
}

fn get_host() -> String {
    // ...
  "foo".to_owned()
}

fn main() -> anyhow::Result<()> {

// WRONG
std::fs::File::create("/tmp/f00")
 .context(format!("Error creating file foo for user {} on host {}", get_username(), get_host()))?;

// RIGHT
std::fs::File::create("/tmp/f00")
 .with_context(|| format!("Error creating file foo for user {} on host {}", get_username(), get_host()))?;

Ok(())
}

By passing a closure to with_context, you defer the evaluation of format! unless File::create actually fails. On success you skip all of this computation and the associated heap allocation, and calls to get_username() and get_host().

error module

Each library crate should have an error module. This should define a thiserror-based error enum named CrateNameError, where CrateName is the pascal case representation of the crate's name.

Many Rust crates and the Rust std lib use an error representation called Error, but this leads to problems with code clarity when dealing with multiple different crates' error types.

The error module should also define a type alias called Result, which aliases to std::result::Result with the default error type set to the crate's error type, e.g.:

#![allow(unused)]
fn main() {
// In `error.rs`
pub enum CrateNameError { /* ... */ }

pub type Result<T, E = CrateNameError> = std::result::Result<T, E>;
}

If necessary (and only if necessary!) the root of each library crate should expose the error module publically. As of now the only reason this would be necessary is to expose the Snafu-generated context selectors to other crates, which is only needed if macros are generating code that needs to use those error types. That's an edge case; in general error should be private.

In all cases, the error enum and Result type alias should be re-exported from the root of the crate, e.g.:

// In lib.rs

// The `error` module should not be public except for the edge case described above
mod error;

// but the Error type and the Result type alias should be part of the public API in the root module
pub use error::{Result, CrateNameError};

// And all funcs should use this public re-export

// Note this is using crate::Result, not crate::error::Result
pub fn some_public_func() -> Result<()> {
  todo!()
}

Other modules in the crate should use crate::CrateNameError, NOT use crate::error::CrateNameError. This is for consistency between crate code and external callers, and also because it's less typing.

Note that when using Snafu, Snafu's context selectors should NOT be re-exported this way, and when referenced within other modules in the crate, those modules should use crate::error, and refer to the context selectors with error::SomeErrorKind.

Using ensure

Anyhow provides the ensure! macro to test a condition and fail with an error if it is false. Prefer this to explicit if or match expressions unless they add some clarity somehow.