Handling Rust Errors Concisely & Consistently

As some of you may know, I am looking to leverage Rust's powerful error-handling capabilities with a minimum of boilerplate code. I am developing what will become a large project and want to make early investments which help keep the code correct, maintainable and efficient.

I've come across a problem with Errors and I wonder 1) if I've missed something significant, and if not, 2) whether there is sufficient interest in exploring a solution to this problem or 3) whether I should simply move forward on my own, building for my needs.

If there is sufficient interest, I'm willing to either join an existing effort or begin a new one to resolve this; while I have some flexibility, reasonable timeliness to a solution is an important component of the solution to me. Please let me know by offering your suggestions/fixes/ideas.

Ok, so what's the problem?

No consistent basis with which to work with Errors

(inconsistencies or issues in bold)

using the Standard Library

  • e.g. std::fmt::Error:

    • is a (marker) struct
    • does implement PartialEq (among other traits)
    • does not implement a kind() method
    • does not define ErrorKind variants
  • e.g. std::io::Error:

    • is a struct encapsulating an enum
    • does not implement PartialEq
    • does implement a kind() method
    • does implements ErrorKind variants which implement PartialEq
  • std::error::Error

    • is a trait
    • does not define or implement PartialEq
    • does not define or implement a kind() method
    • does hot define ErrorKind variants

using error-chain

  • error-chain
    • is Rust's defacto-standard error-handling crate
    • does not implement PartialEq
    • does implement a kind() method
    • does implement ErrorKind variants which do not implement PartialEq

using pattern matching

  • pattern-matching (as opposed to via PartialEq) is often cited as the recommended way to distinguish Errors, given these numerous inconsistencies). There are crates (such as matches) for reducing boilerplate when pattern-matching
  • unfortunately, pattern matching Errors adds boilerplate, obfuscates code and is an incomplete solution. e.g.:
fn string_validator_ref_handles_empty_input() {
  // GivenNotes:
  let input = String::new();
  let expected_result = ErrorKind::ValueNone;

  // When some operation which generates an error occurs
  let result = (&input).validate_ref::<NonEmptyStringValidator>().err().unwrap()
/* Idealized */

// Then ensure the expected error was returned
  assert_eq!(*result, expected_result);
}
/* Reality */

// Then ensure the expected error was returned
  assert!(matches!(*result, expected_result); //Error: pattern unreachable (match doesn't work this way; unintuitive at this level)
  assert!(matches!(*result, val if val == expected_result)); //not ergonomic; Error without PartialEq (e.g. error-chain): binary operation `==` cannot be applied to type `error::ErrorKind`
  assert!(matches!(*result, ErrorKind::ValueNone); //works, but without variable support, important scenarios become unrepresentable:
      // suppose two methods must yield same result.  To ensure consistency through refactorings, a test is developed:
      // assert_eq!(matches(FuncA(badInput), FuncB(badInput)) //not possible, as per above; only constant variants w/PartialEq implemented can be used ergonomically

When considering Error equality, I believe it is out of scope for to consider the semantics of errors (e.g. "Are two instances of SomeError::OtherError equal?" Assuming ::OtherError is a simple unit-struct variant, yes. The fact that ::OtherError could be used by a library or application as a 'catch-all' to represent a variety of error conditions in the real world is out of scope for this proposal. It is expected that the user of that error type will understand when a comparison is a meaningful operation).

Given that the Error type is freqently implemented using different types (marker structs, encapsulating structs, enums, and others) the only dependencies we should take are on its API. To that end, I would like to see:

  • clarification and distinction between .description() and the Display trait for errors
  • a consistent method to dump the Error + it's .cause()'s
  • a consistent .kind() method since many Errors require it; with a default implementation so that those that do not do not need to worry about it

In order to avoid breaking back-compat, I am thinking if there would have to be a layer (macro?) created (which works on stable) which, for the purpose of making comparisons, grants consistency to any currently inconsistent standard library errors, and sets guidelines for future error definitions such that they can be compared consistently. It may be possible (I hope it is possible) to leverage error-chain as a foundation for this work in some way.

I'm interested in your thoughts!

Thanks,
Brad

6 Likes

std::error::Error trait is like Throwable in other languages. IMHO all errors should implement it (so Box<Error> fallback works), but it's not something you use directly.

PartialEq doesn't make sense for most errors that carry some extra information. If an error includes a filename, should that be used or ignored by PartialEq? For an HTTP error you'd probably want HTTP status to be matched, but URL not matched. So PartialEq ends up encoding just one arbitrarily chosen pattern of a match.

I don't see problem with pattern matching. That includes if let and macros,which are a totally legit way of dealing with errors. For a long time try!() was the way to handle them. Custom assert macro seems fine. Also if you implement your custom enum-based error, you can derive PartialEq for it.

.description() for errors is a mistake (it can't customize the message). Ignore it. Focus on Display.

1 Like

I agree that trying to equate errors semantically is likely near-impossible and certainly impractical to get right.

So I am recommending a simple comparison, including all 'extra information' should either match or be false. The user of an HTTP status error would scope the check to just the status code or just the URL if 'semantics' were needed. Otherwise I am advocating for a simple, literal, predictable comparison, to keep the learning curve shallow, and to encourage testability.

I am concerned that the inconsistencies as they exist today discourage testing, lower code quality and do not enable a 'pit of success' for users.

Thank you for the info on .description(). That's good to have confirmed.

For the problem with PartialEq on error types:

Couldn't we add another trait ErrorEq that does exactly what is required to dispatch between different types of errors? This way an error type may implement PartialEq if it is useful for it but any error must also implement the ErrorEq to make it possible to dispatch it when trying to handle them.

BTW, there's discriminant in std::mem - Rust which might help with comparisons.

1 Like

Great suggestions! Thank you both -- I will noodle on both of these.

1 Like

Update for everyone, @withoutboats has introduced a new crate called Failure (docs, video @ 1:17:36) which addresses a great many of my issues with managing Errors in Rust (with and without error-chain).

I don't yet know if it addresses the consistency issue I am raising here, but boats is seeking feedback, so I have brought this up as an issue.

On a personal note, one thing that stunned me is how much more comprehensive solution Failure is than the library I was working on to address these issues (I still have much to learn! :)). While I had explored the custom_derive path (over a macro_rules!-based approach), I had abandoned it, because I did not think to be so bold as to supercede the Error trait. Now that I see what boats has in mind, I believe he is on the correct path. I have (happily) decided to abandon development of my solution and will put my energy into improving Failure, if there is work that I can do to help.

8 Likes

I like the general spirit of this crate, but the naming conventions definitely call for some bikeshedding. I don't want all my error handling code to be riddled with internet meme references! :stuck_out_tongue:

I also think that in a way, this crate reduces the need for error-chain more than it voids it, since by using trait objects, it cuts itself from the (admittedly minority) audience of developers who cannot or do not want to use dynamic memory allocation. The "single error type" approach of error-chain is a better fit for this audience.

1 Like

Hi, @HadrienG, agreed on the general spirit of the Failure crate! Although I'm not sure what memes you're referring to?

Anyway, I'm still a Rust newb, but re: your point about dynamic allocation, I didn't see single vs multiple error type support as driving static vs dynamic memory allocation. (Maybe I'm missing something?) The only place I've seen a driver around dynamic memory allocation is around std::error::Error's .description() trait, where, by taking a only a literal, one can use it to avoid dynamic memory allocation.

My understanding is use of the Display trait will cause an allocation even when using a literal, because of its dependency on std::fmt::format!. My understanding is also that both error-chain and Failure utilize the Display trait.

Please let me know if I've gotten that wrong...

1 Like

"Fail" has quite a bit of history of being used a meme caption, complete with a small family tree of sub-memes. When you combine that with my expectation from other OO languages that traits be mostly named after nouns, not verbs, you will hopefully see where I'm coming from :slight_smile:


Regarding dynamic memory allocation, my point was that you cannot have both of the following:

  1. A single Error struct that can wrap any user-defined concrete error type without prior declaration.
  2. No dynamic memory allocation.

That is because the Error struct must be able to wrap a value of any type with implements the Error/Fail trait, no matter how large, and since Rust requires that the size of a struct returned from a function be finite and known at compile time, this in turn requires use of dynamic memory allocation in the implementation.

The alternative to that is to make a big enum which contains variants for a finite set of supported error types. That is, as far as I understand, what error-chain does. It comes with its own problems, such as error type bloat or non-composability, and I agree that for most people, the dynamic memory allocation based solution will be better. Just wanted to point out that not everyone will be able to use it.


Concerning Display, I'm pretty sure that one could build an allocation-free Formatter that uses a bounded stack-allocated buffer for storage and panics if too much text is written, for uses in no_std contexts where allocation is verboten, but maybe I'm missing something important here.

1 Like

Ok, but just because the word "fail" has been used in pop culture doesn't mean it shouldn't be used at all... I very much doubt the name was chosen as a "meme"

I'd say that 9gag and imgur are pretty awfully low on the list of things that come to mind when I read

#[derive(Fail)]
#[fail(display = "Bad things occurred")]
struct Error;

and that calling the trait Failure would defy convention and create confusion.

(or perhaps "defy convention and create confusion" is a bit strong. But what I mean is, a verb trait like Fail clearly communicates to me that there will be a fail() method I can call, without me even needing to look. Whereas with Failure, I'd have to go check the docs)

1 Like

The author clarified that it wasn't :slight_smile:

Well... to tell you the truth, there is actually no fail() method on that trait.

I never thought about this use of verbs as trait names (to convey the name of the method in single-method traits), and that's actually quite a clever usability trick, thanks for pointing it out! However, it does not seem to apply in this case.

3 Likes

Re: dynamic memory allocation: that's really clear, thanks, @HadrienG.

So, as @withoutboats would like to move forward with publishing the crate, if someone has more opinions on the Fail/Error naming than have already been expressed, please voice them in the github issue.