Error handling's bizarre adventure: Battle Tendency

The fact that some operations fail, errors occur and need to be processed is axiomatic in programming. Over the history of computing, we have had several different models of error handling. With early languages, there was no set standard, and you had to work with what you got from a particular library, in C, there is a special thread-local global variable mechanism via errno that you have to check manually, some languages have the concept of an Error built into them, other follow the exception model.

If you are PHP, you have all of the aforementioned, everything burns, this is hell, God has abandoned us.

Rust sought to eliminate the mess that exceptions and other forms of error-handling can be. This is done by having none of these models, instead leveraging the type system Rust offers.

This eliminates perhaps one of the more important issues of some error-handling models: that you have no way of knowing if an operation is fallible without looking into the source code itself in many cases. That is, the fallibility of an operation (or nullability) is not included in the type information of a function.

To speak less in theoretical, let's address the two main facilities of failure Rust offers.

Panics

You have likely already encountered a panic. C programmers I have met are sometimes tempted to equate a panic with a segmentation fault, but that is not actually true. Segmentation fault necessarily indicates a memory error, attempt to write where you shouldn't or attempt to read where you cannot read from. Some segmentation faults may occur seemingly randomly because the illegally accessed memory may or may not be available depending on a number of factors. Segfaults are difficult to debug and do not by default come with a stacktrace - you have to use a debugger.

A panic is a controlled termination of a program (or a particular thread) on the basis of a failure deemed irrecoverable.

By default, execution will not just cease, but instead, the stack is unrolled and all values are dropped properly, ensuring that proper cleanup has been done and all resources have been returned to the operating system.

Only then will execution cease with a specialized error message and stacktrace, the display of which can be controlled with the RUST_BACKTRACE environment variable.

env RUST_BACKTRACE=full cargo run

This will show the full backtrace if the program panics

What is considered an irrecoverable failure depends on you. In general, Rust considers things that would create illegal memory access, such as indexing indexable structures out of bounds with the [] operator (use .get() instead if you do not know the length ahead and cannot ensure you are indexing with a legitimate index), or electing to not handle the possibility of a None in Option or Err in Result.

In general, you can cause panics easily with the following methods and macros:

  • Option::unwrap(), Result::unwrap()
  • Option::expect(msg), Result::expect(msg) - lets you add a custom message
  • panic!(optional_msg) - formatted like println!() etc.
  • unimplemented!() - same as above
  • todo!() - same as above, is an alias for unimplemented!()
  • unreachable!() - same as above

There are other ways of causing panics, but none are as common as the above.

TIP: Keep in mind that overflowing math panics in debug builds due to debug asserts, but silently overflows in release builds. It is always a good idea to handle the possibility of an overflow with the appropriate safe mathematical method such as .overflowing_add() which let you control the behavior in case an overflow might occur.

However, panics do not necessarily look good, so it is a good idea to handle them if possible.

Result and Error

When something is fallible in a way that can be handled or considered recoverable, then by convention, the Result type is used.

You should be familiar with this type from the chapter on Results and Options. If not, check it out.

Errors are any type that implements the std::error::Error trait. The error trait is a supertrait of both Debug and Display, so your type has to implement these also.

Errors must describe themselves through the Display and Debug traits. Error messages are typically concise lowercase sentences without trailing punctuation:

#![allow(unused)]
fn main() {
let err = "NaN".parse::<u32>().unwrap_err();
assert_eq!(err.to_string(), "invalid digit found in string");
}

Errors may provide cause chain information. Error::source() is generally used when errors cross “abstraction boundaries”. If one module must report an error that is caused by an error from a lower-level module, it can allow accessing that error via Error::source(). This makes it possible for the high-level module to provide its own errors while also revealing some of the implementation for debugging via source chains.

In the most minimal case, if your Error does not require a special description beyond its Display implementation, it is enough to provide an empty impl block to turn your type into an Error:

#![allow(unused)]
fn main() {
use std::fmt;

// most laconic error
#[derive(Debug)]
struct MyError;

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // this is all the error text you get in ***ED, THE STANDARD EDITOR***
        write!(f, "?")
    }
}

impl std::error::Error for MyError {}
}

Error types

Many libraries provide their own error types, and the standard library is no different. For example, this is the IO Error type that's returned by much of the IO, networking and Filesystem functionality:

https://doc.rust-lang.org/std/io/struct.Error.html

This one does not fit the mold, by Errors are typically enums which group all the possible scenarios that might go wrong.

It stands to reason that implementing custom errors all the time and all the required handling (because it is an enum) could get slightly tedious, so a number of error-handling frameworks have been popping up in Rust over the time.

Before we jump into them let's first consider error conversions, as it is an important topic.

Error conversions

In the chapter on Options and Results we mentioned the try ? operator. This allows a function to cease its execution early returning an error that was encountered, otherwise continuing execution with the operations' value unwrapped from the Option or Result.

An Option is not the same type as Result, and a Result with one combination of Ok and Error types is different type from a Result with a different combination.

The operator would be quite unwieldy if we first had to make cumbersome conversions to either Option or a one specific combination of Result<T, E>.

Therefore, the operator can facilitate a number of conversions, depending on the target type. All the conversions implemented by the target type (in the case of an Result, it is the Error type that's important) are available.

If you want to be able to convert any error to a result, use Result<YourOutputType, Box<dyn Error>.

It might be interesting to know that even main() can return this Result to make things more ergonomic:

use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("bar.txt")?;

    Ok(())
}

It is possible to convert every Error type into a trait object, which is why you can use this.

Anyhow

The first of the frameworks important to us with regards to error handling is anyhow:

https://crates.io/crates/anyhow

It provides a flexible concrete Error type built on std::error::Error. It allows for rather easy idiomatic error handling in Rust.

Often it is enough to just use anyhow's provided result type:

#![allow(unused)]
fn main() {
use anyhow::Result;

fn get_cluster_info() -> Result<ClusterMap> {
    let config = std::fs::read_to_string("cluster.json")?;
    let map: ClusterMap = serde_json::from_str(&config)?;
    Ok(map)
}
}

Any Result will be converted to it.

However, you can also attach a context to help the person troubleshooting the error understand where things went wrong. A low-level error like "No such file or directory" can be annoying to debug without more context about what higher level step the application was in the middle of.

use anyhow::{Context, Result};

fn main() -> Result<()> {
    ...
    it.detach().context("Failed to detach the important thing")?;

    let content = std::fs::read(path)
        .with_context(|| format!("Failed to read instrs from {}", path))?;
    ...
}

Keep in mind that using context is best for user-facing errors, since it is hard to process an opaque type like this.

But, if you really needed to, you can try downcasting to a particular error type anyway:

#![allow(unused)]
fn main() {
match root_cause.downcast_ref::<DataStoreError>() {
    Some(DataStoreError::Censored(_)) => Ok(Poll::Ready(REDACTED_CONTENT)),
    None => Err(error),
}
}

Downcasting follows the same convention as the std::any::Any trait.

Anyhow works with any type implementing std::error::Error trait. This may not be the case anymore at the time of this reading, but Options had to be first converted into a Result, for example with a context.

There are also two very handy macros in anyhow:

  1. One-off error messages can be constructed using the anyhow! macro, which supports string interpolation and produces an anyhow::Error.
#![allow(unused)]
fn main() {
return Err(anyhow!("Missing attribute: {}", missing));
}
  1. A bail! macro is provided as a shorthand for the same early return.
#![allow(unused)]
fn main() {
bail!("Missing attribute: {}", missing);
}

In practice, there is very little reason to use anyhow! over bail!.

An easy way to make a concrete Error type for yourself is with thiserror.

Thiserror

This error provides a handy derive you can use on enum of your choosing.

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

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("data store disconnected")]
    Disconnect(#[from] io::Error),
    #[error("the data for key `{0}` is not available")]
    Redaction(String),
    #[error("invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader {
        expected: String,
        found: String,
    },
    #[error("unknown data store error")]
    Unknown,
}
}

Thiserror deliberately does not appear in your public API. You get the same thing as if you had written an implementation of std::error::Error by hand, and switching from handwritten impls to thiserror or vice versa is not a breaking change.

Errors may be enums, structs with named fields, tuple structs, or unit structs.

A Display impl is generated for your error if you provide #[error("...")] messages on the struct or each variant of your enum, as shown above in the example.

The messages support a shorthand for interpolating fields from the error.

  • #[error("{var}")] ⟶ write!("{}", self.var)
  • #[error("{0}")] ⟶ write!("{}", self.0)
  • #[error("{var:?}")] ⟶ write!("{:?}", self.var)
  • #[error("{0:?}")] ⟶ write!("{:?}", self.0)

These shorthands can be used together with any additional format args, which may be arbitrary expressions. For example:

#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum Error {
    #[error("invalid rdo_lookahead_frames {0} (expected < {})", i32::MAX)]
    InvalidLookahead(u32),
}
}

A From impl is generated for each variant containing a #[from] attribute.

Note that the variant must not contain any other fields beyond the source error and possibly a backtrace. A backtrace is captured from within the From impl if there is a field for it.i

#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum MyError {
    Io {
        #[from]
        source: io::Error,
        backtrace: Backtrace,
    },
}
}

This allows you to very effectively convert from one error to another

The task: Simple grep

If you have been using Linux for any measure of time, you should be familiar with the grep program. It's most basic functionality is searching for lines that match a (regex) pattern.

For this task, let's write a simple grep clone. This clone will only operate on one file, and it will only print lines matching a regular expression supplied as the first parameter on the command line.

Writing this program is quite easy, the main task here is to properly handle all possible errors. That is to say:

  • no unwrap()s and such

Use anyhow and thiserror at your discretion to handle all errors.

Be mindful of everything that can fail:

  • missing cli arguments (consider this an error and quit with the correct usage message)
  • missing file
  • file cannot be read
  • invalid regex
  • cannot write into stdout (use std::io::stdout to write out the results)
  • anything else you might think of

Keep in mind that when importing errors, you can always alias them so as to not pollute your namespace with use std::error::Error as IoError.

End product

In the end you should be left with a well prepared project, that has the following:

  • documented code explaining your reasoning where it isn't self-evident
  • optionally tests
  • and an example or two where applicable
  • clean git history that does not contain fix-ups, merge commits or malformed/misformatted commits

Your Rust code should be formatted by rustfmt / cargo fmt and should produce no warnings when built. It should also work on stable Rust and follow the Braiins Standard