From failure to Fehler

About two and a half years ago I wrote a Rust library called failure , which quickly became one of the most popular error handling libraries in Rust. This week, its current maintainer decided to deprecate it, a decision I strongly support. This week, I also released a new and very different error-handling library, called fehler . I wanted to discuss these two libraries briefly.

A brief history of failure

When I released failure, the most popular error handling library by far was error-chain . The only other major error handling library at the time really was a library called quick-error . Both of these libraries had the same basic API: using a macro_rules macro, the user could enumerate all of the different kinds of errors that occur in their program to create a big error enum.

For me, the syntax of error-chain was quite confusing, and I found it verbose and overly complex for the kinds of code I was writing. My experience was that at the application level, most users are not using errors in a way in which they frequently need to match on them and handle each case differently. Instead, my experience was that most applications followed a common pattern:

  • Fallible code is very common, but errors are not (that is, the code that returns Result may be in your hot path, but the Error branch will not be)
  • Many different kinds of errors can occur during program execution, all of them are handled in the same way
  • The way that they are handled is completely agnostic to the kind error, usually some kind of reporting mechanism is involved, perhaps some sort of backpressure (in network applications).

The central idea of failure then was to use a trait object to model the error type, instead of enum. I’m not the person who came up with this: it was how the Error trait in std was always intended to be used. All I did was investigate why people weren’t already using the Error trait in its intended way. I came up with these conclusions:

  • People often want their errors to contain backtraces, because they may need to debug the condition if it represents a programmer mistake.
  • The Error::description method had almost no utility.
  • People often need their errors to be Send, Sync, and ‘static.
  • People sometimes do need to examine the specific error type, which requires downcasting, which also requires 'static . The Error::cause method, for example, returns an object which cannot be downcast because it isn’t bound by 'static .
  • No one feels good writing -> Result<(), Box<dyn Error + Send + Sync + 'static>> .

Thus was born the Fail trait, a “fixed” Error trait. At the same time, failure provided an Error type which was essentially a Box<dyn Fail> with some special features. A quick and easy derive for Fail was provided to avoid the complex macro syntax of earlier libraries. Adoption was widespread and rapid.

Failure’s fatal flaw

The big problem with failure was that it required that everyone buy into a new error trait, failure::Fail . This created compatibility hazards with people who weren’t using failure, resulting in various frustrating experiences. When I wrote failure, I expected a “fixed” error trait to be added to std somewhere, that would be identical to failure, so that failure could ultimately just re-export that trait as failure::Fail .

However, in the long run, things turned out differently. In the end, I made changes to the Error trait itself, as described in RFC 2504 . This meant there was no story for making failure compatible with the std ecosystem.

In the end, many more error libraries have arisen since failure. snafu for example is a bit of a spiritual successor to error-chain, but using proc macros. The crate I would recommend to anyone who likes failure’s API is anyhow , which basically provides the failure::Error type (a fancy trait object), but based on the std Error trait instead of on.

In general, for most libraries I would recommend just manually creating error types and implementing the Error trait for them. If this is too complicated, consider why your API is throwing so many different kinds of errors, and whether your library is doing too many things.

For applications, I recommend using a trait object, rather than an enum, to manage your many kinds of errors. I believe that usually trying to enumerate all of the errors every library you use can throw is more trouble than its worth, and I still assert that for most applications this will lead to higher performance by keeping the happy path less pessimized. I recommend anyhow::Error as a strictly better trait object than Box<dyn Error + Send + Sync + 'static> , not to mention one that is much easier to deal with.

But about Fehler

When I wrote failure, I also had a vision for syntactic changes to Rust that would make error handling more lightweight. What I like about the Result type in Rust is that any function which can raise an error must be annotated as such, at both the definition side and the call site. This is a great feature, that I would never want to lose, because it allows users to identify all points of early return in their functions (i.e. marking fallible function calls with ? is an amazing feature).

What I don’t like about Rust’s error handling story is the impact writing a fallible function has on all of the unfallible return paths. I do not find that I benefit from writing loads of boilerplate Ok() expressions. I would prefer a syntax in which I could treat functions as existing only in the happy path until I need to inspect their fallibility .

This viewpoint is very controversial, and I have no capacity to debate it with anyone who disagrees with me. But Rust has a very powerful macro system, so I don’t have to.

Fehler is a library which provides two macros: an attribute called #[throws] and an expression macro called throw!() . In functions annotated with throws , all returns are on the “Ok” happy path:

#[throws(io::Error)]
fn read_to_string(path: &impl AsRef<Path>) -> String {
    let mut file = File::open(path)?:
    let mut string = String::new();
    file.read_to_string(&mut string)?;
    string // Ok wrapping not necessary
}

If you do want to raise an error in this context, you can, using the throw macro:

#[throws(io::Error)]
fn read_to_string(path: &impl AsRef<Path>) -> String {
    let mut file = File::open(path)?:
    let mut string = String::new();
    file.read_to_string(&mut string)?;

    if string.len() == 0 {
        throw!(io::Error::new(io::ErrorKind::Other, "empty file"));
    }

    string
}

This syntax also supports functions that return Option and some other features, if you want to know more you can read the docs .

I’ve been using Fehler in a personal project for the last two months, and I love it. My only issue with it has been some bad error messages on parse errors that aren’t processed well because it’s a proc macro. I am as convinced as I ever have been that this would be a great addition to Rust. I would encourage anyone else who would like to try writing Rust with syntax like this to use this library and get a feel for how it works. So much of the discussion around error-handling in Rust has been deeply ideological and ungrounded in specific experience: this library provides a way to get an understanding beyond that.

Besides, I have no interest in pushing any further improvement to Rust’s error handling syntax, because of what an enormous drain every attempt to improve that feature has ever been. So I’m very happy to have fehler, which I will keep using in all of my projects, without having to convince anyone else that it’s a good idea.

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章