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.