Er­ror hand­ling story in Rust is still in flux, with people fig­ur­ing out their pre­ferred ways to get er­rors handled and propag­ated. At Stand­ard we’ve been im­ple­ment­ing cus­tom er­ror types1 al­most ex­clus­ively, for both bin­ary and lib­rary crates, to great ef­fect. As I’ve seen many people struggle to ap­ply a sim­ilar pat­tern to their own code, I’m tak­ing an op­por­tun­ity to add to the flux by de­scrib­ing how we’ve made it work for us.

Aside: With er­ror hand­ling be­ing such a hot top­ic, a num­ber of crates to aid in cre­ation of such types ex­ist. Nowadays thiserror ap­pears to be the most pop­u­lar re­com­mend­a­tion and so I will be us­ing the thiserror de­rive macro in this art­icle. The ad­vice here is eas­ily port­able to both manu­ally im­ple­men­ted cus­tom er­ror types as well as er­ror types im­ple­men­ted with the help of other sim­ilar crates.

Early warn­ing: What is be­ing de­scribed here does not res­ult in the most con­cise code. If your ul­ti­mate goal is to limit er­ror-re­lated code to just ? op­er­ator alone, you’re read­ing a wrong art­icle.

Con­text in er­rors

Most com­monly made mis­take I see is done in at­tempt to re­duce the over­head of er­ror propaga­tion to the bare min­imum of a single ? op­er­at­or. In my ex­per­i­ence this leads to un­for­tu­nate amounts of in­form­a­tion loss. An ex­ample of this is:

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("An I/O error occured")]
    Io(#[source] std::io::Error)
    // ...
}

impl From<std::io::Error> for Error { /* ... */ }

In code that does more than just one single I/O op­er­a­tion, Error::Io fails to pre­serve con­text in which this er­ror has oc­curred. “An I/O er­ror oc­curred: per­mis­sion denied” car­ries very little in­form­a­tion and makes it im­possible to fig­ure out what went wrong without re­sort­ing to a de­bug­ger, strace or sim­ilar in­tro­spec­tion tools. In this par­tic­u­lar in­stance not even source code will help!

In the land of dy­namic and boxed er­ror types this prob­lem has already been solved with the context method as seen in lib­rar­ies like failure or anyhow. Not as ob­vi­ously, ex­actly the same pat­tern can be also ap­plied to cus­tom er­ror types, just re­place context with Result::map_err! Here’s an ex­ample of it in ac­tion:

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("Cannot open file")]
    OpenFile(#[source] std::io::Error)
    #[error("Cannot read file contents")]
    ReadFileContents(#[source] std::io::Error)
    #[error("Cannot parse the configuration file")]
    ParseConfig(#[source] serde::Error)
    // ...
}

fn open_config(path: &Path) -> Result<Config, Error> {
    let mut file = File::open(path).map_err(Error::OpenFile)?;
    let mut data = Vec::with_capacity(1024);
    file.read_to_end(&mut data).map_err(Error::ReadFileContents)?;
    parse_config(&data).map_err(Error::ParseConfig)
}

The main high­light to me per­son­ally here is the in­ab­il­ity to for­get about adding the con­text. With anyhow and sim­ilar lib­rar­ies at­tach­ing con­text is op­tion­al, here not wrap­ping the source er­ror into con­text will only get com­piler to re­ject the code.

An­other thing I love about this ap­proach is the ob­vi­ous sep­ar­a­tion of lo­gic and present­a­tion con­cerns. Un­like with context the er­ror mes­sage format­ting is out of the way and away from the lo­gic of ap­plic­a­tion. The lo­gic it­self is also more con­cise, be­cause map_err(Error::Variant) is of­ten sig­ni­fic­antly shorter than the equi­val­ent code us­ing context.

Guidelines

As I spent some time per­fect­ing this way of er­ror hand­ling and propaga­tion, a list of guidelines that I fol­low ma­ter­i­al­ized:

  1. Each unique fal­lible ex­pres­sion should have at least one unique er­ror. Note, that this does­n’t ne­ces­sar­ily mean unique vari­ant per such an ex­pres­sion – a vari­ant could have a field that adds ad­di­tional de­tail to make suf­fi­ciently unique. For in­stance in an ap­plic­a­tion that binds mul­tiple ports, the Error::Bind be­low is per­fectly ad­equate to de­scribe all the sock­ets uniquely:
enum Error {
   #[error("Cannot bind {2} listener to port {1}")]
   Bind(#[source] std::io::Error, u16, &'static str)
}
http_socket.bind(..).map_err(|e| Error::Bind(e, 80, "http"))?;
https_socket.bind(..).map_err(|e| Error::Bind(e, 443, "https"))?;
  1. Don’t im­ple­ment From<OtherErrorType> for Error. These im­ple­ment­a­tions make it way too easy to miss vi­ol­a­tions of the guideline above. The men­tal over­head this im­ple­ment­a­tion adds is huge and is not worth it over sav­ing a couple of map_errs in code.
  2. When ap­plied in a lib­rary, con­sider mark­ing the er­ror types as #[non_exhaustive] or keep er­ror data mostly private. Hav­ing a unique er­ror for each fail­ure mode ne­ces­sar­ily ex­poses (through the pub­lic API) the im­ple­ment­a­tion de­tails of the lib­rary which in turn may make evol­u­tion of the lib­rary dif­fi­cult.
  3. When us­ing enum­s-and-vari­ants as er­rors, avoid du­plic­at­ing Error or sim­ilar syn­onyms when nam­ing vari­ants. I found it easi­est to fol­low my in­stinct and then re­move just the Error. Error::BindError be­comes Error::Bind, Error::OpenFileError be­comes Error::OpenFile and so on.

And a couple of bo­nus ones ap­plic­able more broadly:

  1. Strive to make er­rors use­ful to an end user without an ac­cess to source code or a de­bug­ger. Nobody wants to dig into code if they can avoid it. Doubly so if there’s a burn­ing fire wait­ing to be put out; and
  2. Never format in the er­ror source into the dis­played mes­sage. Users can use Error::source to ob­tain the in­form­a­tion in their present­a­tion lo­gic.

Some of these guidelines were not trivial to come up. It took me months to come up with a good way to name vari­ants. Months! Some other points on the list fol­lowed nat­ur­ally by re­quire­ments (mostly con­text pre­ser­va­tion) I placed on er­rors.

Con­clu­sion

We’ve been us­ing this ap­proach across mul­tiple lib­rar­ies and bin­ar­ies at Stand­ard. This scheme scales very well to hun­dreds of fail­ure modes and the er­rors that sur­face are easy to un­der­stand and deal with. The im­prove­ment most ap­pre­ciable when com­pared with back­traces we get from the likes of Py­thon or JavaS­cript.

This pull re­quest to port memfd from error_chain to thiserror is an ex­ample of these guidelines be­ing ap­plied to an open source lib­rary. Al­though, now that I think about it… manu­ally im­ple­ment­ing the er­ror type prob­ably makes more sense for a lib­rary as small as memfd.

All things con­sidered, er­ror hand­ling in Rust has got­ten to a point where its dif­fi­cult to com­plain about it any­more. I strongly doubt any­thing but very minor in­cre­mental im­prove­ments to er­go­nom­ics are pos­sible. Which is why I will likely keep fol­low­ing these guidelines in my own code for a long time to come. You’re wel­come to ap­ply them to your code as well!