This is another blog in my fledgling series on Rust as I learn the language. In this blog I touch on a few cool crates that can improve your error handling in Rust. It follows nicely from my prior post on logging in Rust. As with that post, this is just an introductory look at error handling. There is a lot more one can learn about error handling (in Rust or otherwise) than I will cover here. Here I will just touch on a few crates that can get you started. Maybe in future posts I will get into more detail on other topics, but there are also good blogs out there already (e.g. this).
Info
As a newbie, please feel free in the comments to elaborate on what I might be doing wrong, could do better or is not “canonical” Rust.
So let’s dive in.
Overview
To be specific, in this blog I will cover defining and using error types specific to your project (application/library). I will also mention a cool crate to make your panic output look a bit better to facilitate debugging, and in doing so touch on Rust’s ‘feature’ mechanism and how you can create and use your own features, in this case to enable/disable the panic output pretification.
std::result::Result<T, E>
Error handling starts with a Result<T, E>
. I will assume you know the
difference between an Option
and a Result
, and in your code, for a given
situation you have decided to use a Result
rather than an Option
. So now you
have to decide what that ‘E’ should be. I will further assume for the sake of
this discussion that you want an error (or errors) that is domain-specific i.e.
you’re not just returning someone else’s error (like Rust’s std::io::Error
).
While there are a few different approaches, what I will cover here is
the thiserror
crate. This crate provides
macros that you use in your code with a custom error enum (or enums). It
generates custom errors as if you created your own custom implementations of
std::error::Error
. This keeps the crate itself out of your public API ---
you’re not ‘locked in’ to using the crate.
Let’s see what this looks like. Let’s say I had a custom configuration
file processing implementation and so had some associated error
conditions I wanted. So I create a ConfigError
enumeration:
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("could not load configuration")]
ConfigLoadError(#[from] io::Error),
#[error("invalid config file format: {0}")]
ConfigParseError(String),
#[error("unknown config property '{key}:{value}'")]
UnknownConfigProperty {
key: String,
value: String,
},
}
There are some key things to point out. Obviously you have the annotations from
thiserror
. Firstly you define derive(thiserror::Error)
on the enum. Then for
each enumeration value you use the #[error]
annotation. A key feature of this
is the message to associate with the error, but even more important is the
ability to reference the values of the enum in the error message. You could use
positional parameters like with the ConfigParseError
or reference by name
values as in the UnknownConfigProperty
. And looking at the first enum value
ConfigLoadError
you can see that you can reference another Error
from which
to get the error info.
Let’s take a look at that last use case in more detail. What does the usage and
output look like if we have a ConfigLoadError
? Let’s expand on the earlier
code and add a function to load configuration from a file into a custom
configuration struct (using toml for the example, hence the Deserialize
annotation on the struct.
use std::io;
use serde::Deserialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("could not load configuration")]
ConfigLoadError(#[from] io::Error),
#[error("invalid config file format: {0}")]
ConfigParseError(String),
#[error("unknown config property '{key}:{value}'")]
UnknownConfigProperty {
key: String,
value: String,
},
}
#[derive(Deserialize)]
pub struct MyConfig {
value: String,
}
pub fn load_config(file_name: &str) -> Result<MyConfig, ConfigError> {
let config_str = std::fs::read_to_string(file_name)?;
let config: MyConfig = toml::from_str(&config_str)
.map_err(|e| ConfigError::ConfigParseError(e.to_string()))?;
Ok(config)
}
In this code, note the return type which uses the ConfigError
we defined
earlier, but also see line 26 which attempts to read the configuration from the
indicated file. std::fs::read_to_string()
returns a std::io::error::Error
and obviously not our custom ConfigError
yet we didn’t do any explicit
conversion (as in the next lines). Yet the “right thing” happens if we run this
without a config file:
2024-04-13T12:54:09.857429824-07:00 [rust_examples] INFO rust_examples:loading config
Error: could not load configuration
Caused by:
No such file or directory (os error 2)
Location:
src/main.rs:17:13
As you can see we get both our custom error could not load configuration
as
well as a “caused by” with the underlying I/O error from fs::read_to_string()
.
This is because thiserror
generates a From
implementation.
Note
As a bonus aside, there’s a cool cargo tool cargo-expand that you can install which will dump out the Rust code of a file after all the macros have done their thing. It’s like running the C/C++ macro processor, if you’re familiar with that.
$ cargo install cargo-expand
$ cargo expand --bin rust-examples --color=always --theme=GitHub --tests config
...
#[allow(unused_qualifications)]
impl ::core::convert::From<io::Error> for ConfigError
}
}
Pretty Backtraces (and Features)
So there you have a quick and dirty intro to using
thiserror
. I encourage you to delve
deeper, and to explore alternative crates such as
anyhow
. But as a quick (and brief) finale,
there’s a cool create called color-eyre which is simple to use and makes
backtraces pretty (color, etc.). Check out the before and after:
To enable this, add the color-eyre
crate and initialize it like so (line 14):
use std::io;
use color_eyre::eyre::Result;
use log::info;
use serde::Deserialize;
use crate::config::ConfigError;
mod config;
fn main() -> Result<()> {
log4rs::init_file("log_config.yaml", Default::default()).unwrap();
#[cfg(feature = "pretty-backtrace")]
color_eyre::install()?;
info!("loading config");
let _ = config::load_config("config.yaml")?;
panic!("oh crap!");
}
But I’m sure you also noticed line 13! That’s how you can dynamically
include or exclude functionality in your code. You also need to declare
the features you have in your Cargo.toml
file
[features]
pretty-backtrace = []
Next steps
There is obviously a lot more we could discuss around error handling and how to
improve it in your code, things like when to use panic!
, use of unwrap()
and expect()
or not, (over)use of ?
to short-circuit returning errors, etc.
But this is a good start. I hope you found this useful.