How did you establish an intuition for working with errors in Rust software?

Hi folks!

I appreciate Rust's rigor for errors. But I keep getting into fights with the compiler.

What practical resources or strategies have you used to develop an intuition for working with errors in Rust software? I'm looking for practice materials to deepen my intuition. And to have a better relationship with the compiler.

Things like:

  • specific programming exercises
  • problem domains that highlight Rust error handling design
  • your learning strategies

I'll continue steeping in my discomfort. But any suggestions in the meantime are much appreciated!

2 Likes

Hi! What's your background like before coming to Rust? Depending on one's "native tongue" before Rust, Rust's error model can either seem like a simplification or a complication. Either way it's very explicit and easy to manage (I find), which I believe pays off in the long run.

Some one-shot questions:

  1. Have you been through the section about Result in the Rust book?
  2. What are you thoughts feelings on Option vs Result, and do you know how to convert between them?
  3. Have you seen anyhow?
  4. Are you curious about what not using anyhow looks like in a production app?
  5. Do you have an opinion about panics?
2 Likes

So do I. And so do I. No worries, I believe this is normal.

Still after a year I am writing Rust as if it was C, or perhaps some mangled subset of C++ and Javascript, at least as far as the compiler will let me.

Long discussions with the compiler are very educational, unlike error messages from most languages.

When I get really stuck I ask here :slight_smile:

Perhaps I am missing out on many of Rust's juicy features with this strategy but it has allowed me to get a lot up and running this year. I'm slowly absorbing Rust's unique features as time allows.

Edit: Ah, you are talking about errors as in std::result.

Same as above really. Errors get handled locally, as they might be in C. Else they get shunted up the call stack with "?". I use the anyhow crate to shape the errors how I like.

2 Likes

:wave: Thanks for the encouragement.

@fosskers to contextualize my questions:

  1. I hail from loosely-typed lands. JavaScript, shell, web applications. I'm sheltered from the real world :grinning_face_with_smiling_eyes:.
  2. I have read through the section on Result a while ago. So I could reread to inform continued practice.
  3. I feel Option and Result serve two important ends in software design in Rust. Option pushes you to think more about your data and their absence. Result makes you decide in every case there could be a failure. I'm still fuzzy on converting between the two. I think that is because I have not had to do so in the Rust programs I have written.
  4. I have seen anyhow! I feel like that would be quite helpful for situations where I need to get something done and do not want to write my own implementation.
  5. Perfect! I will explore that link. Historically do you see more people using anyhow now over writing their own implementations?
  6. I've heard you should avoid panics. I see why and agree. Though I am pragmatic in my ignorance: one application I've written has 10 uses. I can justify a few cases but the rest may arise from my laziness to deciding about the better way to handle those known invalidations.

Well, welcome! Types will deliver you from evil. :slight_smile:

Yes I think you have a good intuition. Converting from Result to Option is more common than the other direction, and is done so via the .ok() method. Also take a look at the filter_map method on iterators. As mentioned elsewhere, ? is a major win of Option/Result and really cleans up code.

The behaviour of anyhow is a sane default for many people when writing applications. It shouldn't be used for libraries. As you can see from the link, I wrote all the From<Error> instances myself. This is both an experiment to see how inconvenient not using anyhow is (hint: not very, it's fine) but also because I need specific control over error values for further logic and localization.

You might also have noticed the unwrap() method, which is morally equivalent to writing a panic. unwrap() usage can be justified if you're very very certain that it will succeed, but in most cases if you find yourself writing an unwrap or a panic, there's a problem with your design. Rust is helpful in that it guides us naturally toward good practice. If we find ourselves really struggling with something (this also applies to lifetimes), we're probably doing it wrong.

Remember too that main() can return a Result! So you can ? to your heart's content and let errors bubble all the way up.

2 Likes

If it's any comfort, I find error handling to be one of the trickier aspects of Rust even after becoming very comfortable with most other areas of the language. There are many ways to do things and it's rarely clear to me what the best choice is in a given situation, other than the general (and very useful!) recommendation of using anyhow for binaries and custom error types for libraries, possibly with the help of a library like thiserror to make things a bit more convenient.

2 Likes

I use Result/Option when the error can arise in normal operation, usually due to user error. I use panic!() when the error is due to a bug. For example, I have cases where I pre-populate a HashMap. If a get() fails, that's something I need to fix.

I find that using expect("Something sensible") helps me find the source of the problem faster than had I used unwrap().

3 Likes

What I'm about to say isn't popular here, but I'd say that you shouldn't be ashamed of panicking. Especially when you have few users of a specialized tool, letting things you haven't considered just panic is fine, so long as the users have a tight feedback loop with you so you can fix the code and get them a new version promptly.

If you're writing something for millions of people to use, then sure, you probably want to do something more careful than just .unwrap()ing things. But if it's just something like a short-running batch tool for internal use, especially if it's for a small number of users who need to be trained in it anyway, you don't need to have it start out with amazing error handling. You can always update it as you go along to handle the errors that people actually run into.

5 Likes

Just to throw out another experience:

Rust's error handling is never anything I've had a problem with, because I don't expect more out of it than I put into it. It's just normal code, and errors are just normal data. I suspect that having difficulty in handling errors is perhaps more about expecting a lot out of them, like getting accurate backtraces and such things, which can make it complicated... but you may not even need that.

For example, if I'm writing an application, one of the first things I do is setup tracing, so it is pretty trivial to read a trace dump and figure out what my code is doing when it fails. I don't need the error handlers to do that stuff. Of course, everyone's requirements are different, so one probably needs to be more specific about what they expect of errors to get meaningful feedback on how to make them do that, because Rust itself doesn't dictate much about how you write error handling code. And it may be a XY problem where what you really need is good tracing functionality instead, so you can avoid the need to create errors in the first place.

2 Likes

This.

Something that I love about Rust's error handling is that since errors are just values, there's no additional complication around writing "exception-safe" code or adding rethrows annotations, or anything like that.

In order to work efficiently with Result and its relatives, you have to know your way around its combinators (e.g. unwrap_or_else(), and_then(), etc.) and also something about how specific APIs like to deal with failure. For example, you can collect() an Iterator<Item=Result<T, E>> into a Result<Container<T>> without spelling out any of the error handling in a long-winded way.

Another thing is that when you are writing a library, it can be convenient (and some may advise you) to define a single error type, possibly with an enum that downstream code can match on in order to differentiate between different kinds of error conditions. However, I often find that unnecessary when the reason of the error is unequivocally obvious from the context. E.g. a parse_json_string() function can be reasonably assumed to only ever fail due to syntactic errors, and not because it failed to connect to an HTTP endpoint, for instance.

These are more like observations of how I personally do my error handling; however, I have to say I really don't think it's anything special or conceptually hard. Know your standard library functions, use the ? operator liberally, use crates which are well-designed enough to deal with errors as easily and possible, and you'll be fine.

2 Likes

Excellent question, thanks! Let me share how I've learned about error handling. I am afraid this won't work as a best way to actively learn the topic (as this is just a collection of anecdata), but it might still be useful.

Haskell's IO

Java's checked exceptions, Rust's Result and Haskell's IO monad all share the property that they are contagious. If f returns a Result, and g calls f, then g most likely needs to return a Result as well. This is a general property of function coloring, common to all effect-like things. What Haskell specifically made me realize is the core question of error management:

Which part of code can not return an error?

In Haskell, threading IO everywhere is really painful, so it forces you to cleanly separate the code into code inside IO, and pure code which can't do IO, and you win if the ratio between the two is 1:9 or thereabouts.

This transfers to Rust -- the most important thing is carving out the subset of logic which doesn't have to deal with errors, and then pushing this subset outwards.

Haskell's Mondas

The second great influence of Haskell for me was just the grunt skill of transforming Monads. It's pretty hard to wrap you head around all of the Result/Option combinators in a brute force way, but it's easy to see all of them as just special cases of few basic combinators. That's pretty powerful, you get to know things like "of course there should be a way to collect an Iterator<Item = Result<T, E>> to Result<Vec<T>, E> because that's Traversible" without reading about this specific case in the docs.

Exceptions in Kotlin/Python

In both of this languages, I had to implement the same thing:

In the context of long-running "server" program, call external process which can fail

This taught me that Exception-based approaches fall down when the basic question ("is this situation truly exceptional?") is hard to answer. On the one hand, external process failing is kind of exceptional, and language APIs do use exceptions. On the other hand, in my particular context failure was an expected outcome. This lead to me planting a whole lot of bugs in the respective subsystems.

Midori Error Model

http://joeduffyblog.com/2016/02/07/the-error-model/ -- this is a must-read article about error handling. The two core insights (which are not novel, reflected in many other systems, but not always as explicitly articulated):

  • distinguish between programmer errors (bugs), which should be managed by fixing the code, and environmental errors -- things that can go wrong during normal operation of the program
  • establish isolation boundaries -- rather than managing specific error conditions (E1 || E2 || E3), make it possible to recover any error (∀ E).

Simple testing can prevent most critical failures

https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-yuan.pdf -- another "I read a paper and was enlightened" case. The paper makes the following observation:

  • Most of critical failures of complex systems a due to bugs in error-handling code (ie, a benign transitive error leads to the whole cluster of machines going belly up due to a bug in a catch clause).
  • the bugs in catch clauses are really obvious.
  • they are not discovered because the code in catch is never executed during dev because errors never happen.

This leads to a very practical advice -- do not have special rare code paths, as much as possible, strive to either use the same code path for all situations, or to artificially inflate frequency of rate code paths. In terms of code, rather than writing if let Err(err) = failable_op() { logic } , put the logic into a Drop impl for some type.

Related ideas are:

  • crash only software (do not have dedicated "orderly shutdown" sequence, make "emergency shutdown" work nice)
  • sysadmin rule of "restore from the backup every night"
  • fault injection (chaosmonkey)

IntelliJ's red lightbulb

When some part of IntelliJ throws, the user sees a red lighbulb with logs & Exception stack trace. The IDE continues to work normally. And, if you use IntelliJ long enough, you will see the red lighbulb. This for me was a very practical rendition of isolation boundaries from Midori model.

An IDE is a very large and very complex bit of software. It is impossible to just will the bugs out of existence, IDEs are big enough to always be buggy. So, instead one should embrace the bugs, and make sure that the overall user experience is not compromised to much (bugs don't crash the whole system, are easy to report (for users) and observe (for devs), and fixes are timely).

Adding HTTP interface to a service

A long while ago I worked on some Rust service which was also exposed via HTTP using iron framework. I've noticied a curious pattern -- the HTTP layer used meticulously crafted enum Error, which united a dozen of dissimilar errors from various components. Ultimately, all the errors actually resulting in a display of 4xx or 5xx page. That is, all of the carefully preserved information about the error was erased into a single status-code and an error message. I was able to greatly simplify the code by replacing enum Error with struct HttpError(code: u32, msg: String). I've also learned very concretely about the benefits of type-erased error handling, a-la anyhow crate.

snafu

Reading the docs of the snafu crate taught me about the other approach -- the error kind pattern. One of the ideas of snafu is (roughly) that From impls for error types are public API, so, ideally, you should be able to create errors without resorting to From impls. More generally, there's Error API which you use as a library implementor, and there's Error API which is used by the consumers of your library, and its important to not mix the two. As usual, every abstraction has two interfaces -- one for the user, one for the implementor.

14 Likes

Finally finished reading the references, and just wanted to say thank you for your thorough comment and the Joe Duffy article especially.

5 Likes