Ergonomic error handling in Rust
There are many ways you can handle errors in Rust programs: panic at every fallible situation, propagate foreign errors up the call stack, craft your own error type.
I’ve recently fallen into a comfortable pattern when dealing with errors in Rust and believe it serves great for small or large projects.
This post will walk through a few different ways to handle errors
and then ultimately introduce the
snafu
library as a clean and ergonomic solution to the elusive task of
dealing with erroneous behaviour.
Contents
Panic! at the disco
Let’s say we have some arbitrary text file on disk that we want to read from, and depending on the content of the file, we want to write back some bytes to it. Simple enough?
use std::{
fs::{self, OpenOptions},
io::prelude::*
};
const PATH: &str = "file.txt";
fn run() {
let content = fs::read_to_string(PATH).unwrap();
let mut file = OpenOptions::new()
.write(true)
.append(true)
.open(PATH)
.unwrap();
if content.trim() == "hello" {
.write_all(b"world!").unwrap();
file}
Ok(())
}
fn main() {
;
run()}
This program can fail in a few different ways:
- The file doesn’t exist.
- The contents of the file aren’t valid UTF-8.
-
The call to
write_all
gets interrupted.
We are handling each of these instances with our calls to
unwrap
on the fallible function calls. However, instead of being able to
recover from an error if it occurs, the current thread
panics
, which, if it is the main thread, terminates all threads and ends
the program with code 101
.
Recoverable errors with Result<T, E>
Instead of calls to panic!
each time the program can
fail, we should be writing more robust code by giving our caller a
chance to recover from this behaviour.
use std::{
fs::File,
io::{self, prelude::*},
};
const PATH: &str = "file.txt";
fn run() -> Result<(), io::Error> {
let content = fs::read_to_string(PATH)?;
let mut file = OpenOptions::new()
.write(true)
.append(true)
.open(PATH)?;
if content.trim() == "hello" {
.write_all(b"world!")?;
file}
Ok(())
}
fn main() {
match run() {
Ok(()) => {},
Err(e) => eprintln!("{}", e)
}
}
The Result
type is an enum
which has an
Ok
and an Err
variant. The
Ok
variant holds a generic value T
that
gets returned in the case that no errors have occurred. The
Err
variant holds a generic value E
,
usually an error type, if an error has occurred.
The ?
operator only works when the function we’re
applying it to returns a Result
in which whose
Err
variant contains the error type we wish to
propagate. It is essentially a shorthand for the following code:
match result {
Ok(v) => v,
Err(e) => Err(e)
}
This implementation allows for our caller to recover from any erroneous behaviour. However, we can only return one type of error from our function. This prompts anyone who wishes to possibly return more than one error type to create their own type that contains multiple variants.
Crafting custom error types
We can craft our own custom error type as a Rust
enum
and add multiple variants to it. We can then define a
Result
type which we can use throughout our program
which contains our custom error type as the error to propagate.
In order to demonstrate the need for multiple error types, we’ll
extend the functionality of this tiny application to include a call
to some API endpoint that is written in the text file, using the
reqwest
library.
use std::{
fmt::{self, Display},
fs::{self, OpenOptions},
io::{self, prelude::*},
};
use reqwest::blocking;
type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug)]
enum Error {
io::Error),
IoError(reqwest::Error)
ApiError(}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Error {
Error::IoError(err)
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Error {
Error::ApiError(err)
}
}
impl Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::IoError(e) => write!(f, "IO Error: {}", e),
Error::ApiError(e) => write!(f, "Api Error: {}", e)
}
}
}
const PATH: &str = "file.txt";
fn run() -> Result<()> {
let content = fs::read_to_string(PATH)?;
let mut file = OpenOptions::new()
.write(true)
.append(true)
.open(PATH)?;
let response = blocking::get(content.trim())?;
.write_all(
file
response.text()?
.as_bytes()
?;
)
Ok(())
}
fn main() {
match run() {
Ok(()) => {}
Err(e) => eprintln!("{}", e),
}
}
In order to apply our nice shorthand ?
operator, we
have the implement the
From
trait for each error type we wish to add to our custom
enum
. In addition, in order for main
to be
able to print out the error message, we must implement
Display
for our custom error type, matching on each variant.
This implementation solves the problem of dealing with multiple error types in a given function. Having our own custom error type also allows for other users of our application to deal with our various custom error type variants using a similar approach. However, as we shall see, we can avoid some of this tedium by bringing in a third party.
Enter snafu
The snafu
library allows for the easy assignment of
foreign errors into domain-specific errors while also adding
contextual information. Whenever a foreign error type is
encountered, we can easily pin it to our own custom typing by adding
a source
attribute.
For instance:
use std::{
fs::{self, OpenOptions},
io::{self, prelude::*},
};
use reqwest::{self, blocking};
use snafu::Snafu;
type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Snafu)]
enum Error {
#[snafu(context(false), display("IO Error: {}", source))]
{ source: io::Error },
Io
#[snafu(context(false), display("Api Error: {}", source))]
{ source: reqwest::Error },
Api }
const PATH: &str = "file.txt";
fn run() -> Result<()> {
let content = fs::read_to_string(PATH)?;
let mut file = OpenOptions::new()
.write(true)
.append(true)
.open(PATH)?;
let response = blocking::get(content.trim())?;
.write_all(
file
response.text()?
.as_bytes()
?;
)
Ok(())
}
fn main() {
match run() {
Ok(()) => {}
Err(e) => eprintln!("{}", e),
}
}
The
Snafu
macro implements all the necessary traits in order to begin using
our custom error type in functions.
Crafting our type this way not only let’s us avoid implementing traits ourselves but also gives us the added benefit of easily adding in custom fields to our error types as contextual information.
This tiny example doesn’t show all of the functionality the
snafu
library has to offer, so I suggest you have a
look at the official
documentation.
Fin
Quality Rust code should never panic when it doesn’t absolutely need
to, and knowing this you should make the best of the situation by
crafting your own error types that other people can use in their
applications. You can either make it tedious, by doing it by hand,
or by using the nifty snafu
library to do the job.
I'm Liam.
I'm currently a software engineer intern at
1Password on
the Filling and Saving team, where I primarily work on the
browser extension
and related infrastructure.
I also study computer science at
McGill University.
I like developer tooling, distributed systems, performance
engineering and compiler design.
You can reach out to me via email at liam@scalzulli.com.