Blog post

More than you've ever wanted to know about errors in Rust

2022-06-30T18:00:00

16 minute read

This blog post is powered by shuttle! The serverless platform built for Rust.


To quote the Rust Book, 'errors are a fact of life in software'. This post goes over how to handle them.

Before talking about recoverable errors and the Result type, let's first touch on unrecoverable errors - a.k.a panics.

Panics

Panics are exceptions a program can throw. It stops all execution in the current thread. When a panic is thrown it returns a short description of what went wrong as well as information about the position of the the panic.

1fn main() {
2    panic!("error!");
3    println!("Never reached :(");
4}
5

Running the above causes:

1thread 'main' panicked at 'error!', examples\panics.rs:2:5
2

They are similar to throw in JavaScript and other languages, in that they don't require an annotation on the function to run and they can pass through function boundaries. However in Rust, panics cannot be recovered from, there is no way to incept a panic in the current thread.

1fn send_message(s: String) {
2    if s.is_empty() {
3       panic!("Cannot send empty message");
4    } else {
5        // ...
6    }
7}
8

The send_message function is fallible (can go wrong). If this is called with an empty message then the program stops running. There is no way for the callee to track that an error has occurred.

For recoverable errors, Rust has a type for error handling in the standard library called a Result. It is a generic type, which means the result and error variant can basically be whatever you want.

1pub enum Result<T, E> {
2    Ok(T),
3    Err(E),
4}
5

Basic error creation and handling

At the moment our send_message function doesn't return anything. This means no information can be received by the callee. We can change the definition to instead return a Result and rather than panicking we can early return a Result::Err.

1fn send_message(s: String) -> Result<(), &'static str> {
2    if s.is_empty() {
3        // Note the standard prelude includes `Err` so the `Result::Err` and `Err` are equivalent
4        return Result::Err("message is empty")
5    } else {
6        // ...
7    }
8    Ok(())
9}
10

Now our function actually returns information about what went wrong we can handle it when we call it:

1if let Err(send_error) = send_message(message) {
2    show_user_error(send_error);
3}
4

Rust knows when a Result is unused.

In the above example we inspect the value of the item and branch on it. However, if we didn't inspect and handle the returned Result then the Rust compiler gives us a helpful warning about it so that you don't forget to explicitly deal with errors in your program.

1|     send_message();
2|     ^^^^^^^^^^^^^^^
3= note: `#[warn(unused_must_use)]` on by default
4= note: this `Result` may be an `Err` variant, which should be handled
5

Examples of Result in the standard library

Result can be found in most libraries. One of my favorite examples is the return type of the FromStr::from_str trait method. With str::parse (which uses the FromStr trait) we can do the following:

1fn main() {
2	let mut input = String::new();
3	std::io::stdin().read_line(&mut input).unwrap();
4
5	match input.trim_end().parse::<f64>() {
6		Ok(number) => {
7			dbg!(number);
8		}
9		Err(err) => {
10			dbg!(err);
11		}
12	};
13}
14

(We'll ignore the unwrap for now 😉)

1$ cargo r --example input -q
210
3[examples\input.rs:7] number = 10.0
4
5$ cargo r --example input -q
6100
7[examples\input.rs:7] number = 100.0
8
9$ cargo r --example input -q
10bad
11[examples\input.rs:10] err = ParseFloatError {
12    kind: Invalid,
13}
14

Here we can see when we type in a number we get a Ok variant with the number else we get a ParseFloatError

Files, networks and databases

All errors occur when you interact with the outside world or things outside the Rust runtime. One of the places where a lot of errors can occur is interacting with the file system. The File::open function attempts to open a file. This can fail for a variety of reasons. The filename is invalid, the file doesn't exist or you simply don't have permission to read the file. Notice the errors are well-defined and known before-hand. You can even access the error variants with the kind function and in order to implement your program logic or return an instructive error message to the user.

Aliasing Results and errors

When you're working on a project you'll often find yourself repeating yourself when it comes to return types in function signatures:

1fn foo() -> Result<SomeType, MyError> {
2...
3}
4

To give a concrete example, all functions which operate on the file system have the same errors (file not exists, invalid permissions). io::Result is a alias over a result but means that every function does not have to specify the error type:

1pub type Result<T> = Result<T, io::Error>;
2

If you have an API which has a common error type, you may want to consider this pattern.

The question mark operator

One of the best things about Results is the question mark operator, The question mark operator can short circuit Result error values. Let's look at a simple function which uploads text from a file. This can error in a bunch of different ways:

1fn upload_file() -> Result<(), &'static str> {
2    let text = match std::fs::read_to_string("file.txt").map_err(|_| "read file error") {
3        Ok(value) => value,
4        Err(err) => {
5            return err;
6        }
7    };
8    if let Err(err) = upload_text(text) {
9        return err
10    }
11    Ok(())
12}
13

Hang on, we're writing Rust not Go!

If a ? is postfixed on to a Result (or anything that implements try so also Option) we can obtain a functionally equivalent outcome with a much more readable and concise syntax.

1fn upload_file() -> Result<(), &'static str> {
2    let text = std::fs::read_to_string("file.txt").map_err(|_| "read file error")?;
3    upload_text(text)?;
4    Ok(())
5}
6

As long as the calling function also returns a Result with the same Error type, ? saves a ton of explicit code being written. Moreover, the question-mark implicitly runs Into::into (which is automatically implemented for From implementors) on the error value. So we don't have to worry about converting the error before we use the operator:

1// This derive an into implementation for `std::io::Error -> MyError`
2#[derive(derive_enum_from_into::EnumFrom)]
3enum MyError {
4    IoError(std::io::Error)
5    // ...
6}
7
8fn do_stuff() -> Result<(), MyError> {
9    let file = File::open("data.csv")?;
10    // ...
11}
12

We will look at more patterns for combining error types later!

The Error trait

The Error trait is defined in the standard library. It basically represents the expectations of error values - values of type E in Result<T,E>. The Error trait is implemented for many errors and provides a unified API for information on errors. The Error trait is a bit needy and requires that the error implements both Debug and Display. While it can be cumbersome to implement we will see some helper libraries for doing so later on.

In the standard library VarError (for reading environment variables) and ParseIntError (for parsing a string slice as a integer) are different errors. When we interact them we need to differentiate between the types because they have different properties and different stack sizes. To build a combination of them we could build a sum type using an enum. Alternatively we can use dynamically dispatched traits which handle varying stack sized items and other type information.

Using the above mentioned try syntax (?) we can convert the above errors to be dynamically dispatched. This makes it easy to handle different errors without building enums to combine errors.

1fn main() -> Result<(), Box<dyn std::error::Error>> {
2    let key = std::env::var("NUMBER_IN_ENV")?;
3    let number = key.parse::<i32>()?;
4    println!("\"NUMBER_IN_ENV\" is {}", number);
5    Ok(())
6}
7

While this is an easy way to handle errors, it isn't easy to differentiate between the types and can make handling errors in libraries hard. More information on this later.

The Error trait vs Results and enums

One thing when using an enum is we can use match to branch on the enum error variants. On the other hand, with the dyn trait unless you go down the down casting path it is very hard to get specific information about the error:

1match my_enum_error {
2    FsError(err) => {
3        report_fs_error(err)
4    },
5    DbError(DbError { err, database }) => {
6        report_db_error(database, err)
7    },
8}
9

For reusable libraries it is better to use enums to combine errors so that users of your library can handle the specifics themselves. But for CLIs and other applications using the trait can be a lot simpler.

Methods on Result

Result and Option contains many useful functions. Here are some functions I commonly use:

Result::map

This maps or converts the Ok value if it exists. This can be more concise than using the ? operator.

1fn string_to_plus_one(s: &str) -> Result<i32, std::num::ParseIntError> {
2    s.parse::<i32>().map(|num| num + 1)
3}
4

Result::ok

Useful for converting Results to Options

1assert_eq!(Ok(2).ok(), Some(2));
2assert_eq!(Err("err!").ok(), None);
3

Option::ok_or_else

Useful for going the other way in converting from Options to Results

1fn get_first(vec: &Vec<i32>) -> Result<&i32, NotInVec> {
2    vec.first().ok_or_else(|| NotInVec)
3}
4

Error handling for iteration

Using results in iterator chains can be a little confusing. Luckily Result implements collect. We can use this to short circuit an iterator if an error occurs. In the following, if all the parses succeed then we get collected vec of numbers result. If one fails then it instead returns a Result with the failing Err.

1fn main() {
2	let a = ["1", "2", "not a number"]
3		.into_iter()
4		.map(|a| a.parse::<f64>())
5		.collect::<Result<Vec<_>, _>>();
6	dbg!(a);
7}
8
1[examples\iteration.rs:6] a = Err( ParseFloatError { kind: Invalid, }, )
2

Removing the "not a number" entry

1[examples\iteration.rs:3] a = Ok( [ 1.0, 2.0, ], )
2

Because Rust iterators are piecewise and lazy the iterator can short circuit without evaluating parse on any of the later items.

More Panic

Special panics

todo!(), unimplemented!(), unreachable!() are all wrappers for panic! () which but are specialized to their situation. Panics have a special ! type, called the 'never type', which represents the result of computations that never complete (also means it can be passed anywhere):

1fn func_i_havent_written_yet() -> u32 {
2    todo!()
3}
4

Sometimes there is Rust code which the compiler cannot properly infer is valid. For this type of situation, the unreachable! panic can be used:

1fn get_from_vec_else_zero(a: Vec<i32>) -> i32 {
2    if let Some(value) = a.get(2) {
3        if let Some(prev_value) = a.get(1) {
4            prev_value
5        } else {
6            unreachable!()
7        }
8    } else {
9        0
10    }
11}
12

Unwrapping

unwrap is a method on Result and Option. They return the Ok or Some variant or else panic...

1// result.unwrap()
2
3let value = if let Ok(value) = result {
4    value
5} else {
6    panic!("Unwrapped!")
7};
8

The uses-cases for this are developer error and situations the compiler can't quite figure out. If you are just trying something and don't want to set up a full error handling system then they can be used to ignore compiler warnings.

Even if the situation calls for unwrap you are better off using expect which has an accompanying message - you'll be thanking your past self when the expect error message helps you find the root cause of an issue 2 weeks down the line.

Panics in the standard library

It is important to note that some of the APIs in the standard library can panic. You should look out for these annotations in the docs. One of them is Vec::remove. If you use this you should ensure that the argument is in its indexable range.

1fn remove_at_idx(a: usize, vec: &mut Vec<i32>) -> Option<i32> {
2    if a < idx.len() {
3        Some(vec.remove(a))
4    } else {
5        None
6    }
7}
8

Handling multiple errors and helper crates:

Handling errors from multiple libraries and APIs can become challenging as you have to deal with a bunch of different types. They are different sizes and contain different information. To unify the types we have to build a sum type using an enum, in order to ensure they have the same size at compile time.

1enum Errors {
2    FileSystemError(..),
3    StringParseError(..),
4    NetworkError(..),
5}
6

Some crates for making creating these unifying enums easier:

thiserror

thiserror provides a derive implementation which adds the Error trait for us. As previously mentioned, to implement Error we have to implement display and thiserrors' #[error] attributes provide templating for the displayed errors.

1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum DataStoreError {
5    #[error("data store disconnected")]
6    Disconnect(#[from] io::Error),
7    #[error("the data for key `{0}` is not available")]
8    Redaction(String),
9    #[error("invalid header (expected {expected:?}, found {found:?})")]
10    InvalidHeader {
11        expected: String,
12        found: String,
13    },
14    #[error("unknown data store error")]
15    Unknown,
16}
17

anyhow

anyhow provides an ergonomic and idiomatic alternative to explicitly handling errors. It is similar to the previously mentioned error trait but has additional features such as adding context to thrown errors.

This is really, really, useful when you want to convey errors to an application's users in a context-aware fashion:

1use anyhow::{bail, Result, Context};
2
3fn main() -> Result<()> {
4    println!("Hello World!");
5    func1().context("while calling func1")?;
6    Ok(())
7}
8
9fn func1() -> Result<()> {
10    func2().context("while calling func2")
11}
12
13fn func2() -> Result<()> {
14    bail!("Hmm something went wrong ")
15}
16
1Error: while calling func1
2
3Caused by:
4    0: while calling func2
5    1: Hmm something went wrong
6

Similar to the Error trait, anyhow suffers from the fact you can't match on anyhow's result error variant. This is why it is suggested in anyhow's docs to use anyhow for applications and thiserror for libraries.

eyre

Finally, eyre is a fork of anyhow and adds more backtrace information. It's highly customisable and using color-eyre we get colors in our panic messages - a little color always brightens up the dev experience.

1The application panicked (crashed).
2Message:  test
3Location: examples\color_eyre.rs:6
4
5  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BACKTRACE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6                                ⋮ 13 frames hidden ⋮
7  14: core::ops::function::FnOnce::call_once<enum$<core::result::Result<tuple$<>,eyre::Report>, 1, 18446744073709551615, Err> (*)(),tuple$<> ><unknown>
8      at /rustc/7737e0b5c4103216d6fd8cf941b7ab9bdbaace7c\library\core\src\ops\function.rs:227
9                                ⋮ 17 frames hidden ⋮
10

Shuttle: Stateful Serverless for Rust

Deploying and managing your Rust web apps can be an expensive, anxious and time consuming process.

If you want a batteries included and ops-free experience, try out Shuttle.

Share this article

Let's make Rust the next language of cloud-native

We love you Go, but Rust is just better.