8 ms·
Rust: Enums to Wrap Multiple Errors
- aazaa 5y agoI find this kind of article that explains how to do something with vanilla Rust valuable because many articles will instead explain how to use a crate that offers similar functionality. This is the only way to know if a crate can actually pull its weight before you decide to adopt it.
- nagisa 5y agoThe fact that failure conditions from both `File::open` and `read_to_string` become an `IoError` is a significant roadblock to making these errors useful. The mechanism as described in this blog post also fails to introduce contextual information about the reason a failure has occurred. This means that errors, if implemented as described in this post, either formatted or handled don't give sufficient information to the caller/user on how to deal with the error. EDIT: I strongly recommend to use `op.map_err(|e| SomeError::Open(e, filename))?` and `op.map_err(SomeError::Read)?` as an alternative when propagating the errors. It is more typing at the location of propagation than just `?`, but the errors this approach produces are immediately actionable regardless of whether they are printed to the user or handled by a caller. Provided, of course, this pattern is applied consistently.
- matklad 5y agoHeh, I opened the comment section to link https://kazlauskas.me/entries/errors.html https://kazlauskas.me/entries/errors.html and https://sled.rs/errors.html https://sled.rs/errors.html :-)
- Macha 5y agoYou can match on std::io::ErrorKind, which is available by calling kind() on your error. This effectively has the same result as error inheritance hierarchies in Java and C#. I don't think you really would treat a failure in File::open and read_to_string() differently in most applications. If you have a function doing file IO you might retry reading a read_to_string() error, but you know that the file exists because you know which function you just called. If you're at higher level of abstraction, where something is calling both open() and read_to_string() for you, are you really going to handle it differently that frequently whether it's a missing file or a file on an unavailable storage medium?
- nagisa 5y agoIn my personal experience distinct handling of errors like these does indeed come up significantly more rarely. What ends up mattering to me much more often is how these errors are presented to the user, or what ends up in the logs in production. In those cases something like error: failed to read contents of `foo` caused by: a physical I/O error has occurred (...) and error: failed to open `foo` caused by: permission denied (...) are significantly more actionable than just the underlying I/O errors by themselves. To the point where a person reading these messages has a shot at addressing the problem without any prior knowledge of the code base. In `anyhow` adding such context via its `context` family of methods seems fairly well accepted. I hope that my comment demonstrates there's no reason why `enum` based approach would need to be any worse than what `anyhow` can achieve.
- Macha 5y agoFor your logs you're going to print the error's default message anyway, so you should have the underlying message of the std::io::Error as long as it's listed in the source of your wrapped error.
- shepmaster 5y ago> you should have the underlying message of the std::io::Error This is a point of debate[1] among the error-handling working group. [1]: https://github.com/rust-lang/project-error-handling/issues/44 https://github.com/rust-lang/project-error-handling/issues/4...
- shepmaster 5y ago> how these errors are presented to the user, or what ends up in the logs in production. In those cases something like [...] are significantly more actionable than just the underlying I/O errors by themselves. To the point **where a person reading these messages has a shot at addressing the problem** without any prior knowledge of the code base. (emphasis mine) This is a huge belief of mine as well. I call it a "semantic stacktrace". I actually go as far as to say that _most of the time_ backtraces in errors are an antipattern, as it gives a false sense that an error is actionable ("oh, just look in the code!"). I even try to make it such that each leaf error is constructed in exactly one place so that grepping for the error identifies that one location.
- shepmaster 5y agoCheck out my sibling comment about SNAFU, which addresses your concerns, if I understand them.
- Macha 5y agoFor reducing the code to implement this pattern, `thiserror` is nice. `derive_more` also has similar functionality, and I tend to use that more personally because I use its other derives, and while thiserror has a slightly nicer API (derive_more will assume a unit struct's field is it's source, which is wrong more often than not in my codebase, since I avoid overdoing nested error enums), it's not worth adding another dep to do what the dep I already have can do: https://jeltef.github.io/derive_more/derive_more/error.html https://jeltef.github.io/derive_more/derive_more/error.html
- stormbrew 5y agohuh that's cool. If it also had a "better derive(Default)" as well I'd probably end up using this in almost everything I write tbh.
- onei 5y agoThe downside to derive_more from what I can tell is that it impacts compile times fairly noticeably compared to the equivalent handwritten code. YMMV, but I've seen it removed from some crates for that specific reason.
- dljsjr 5y agoPropagation without needing to use boxed trait objects can also be accomplished using `anyhow`[1]/`eyre`[2], which have nice downcasting API’s for recovering the original error type if you know what the possibilites could be. I only bring it up because they aren’t mentioned until the end of the article and only in passing but they offer really nice features for attaching context and downcasting that makes up for the pseudo-type-erasure. 1: https://github.com/dtolnay/anyhow https://github.com/dtolnay/anyhow 2: https://github.com/yaahc/eyre https://github.com/yaahc/eyre
- shepmaster 5y ago> Propagation without needing to use boxed trait objects You can also use enums, as shown in the post. You don't need trait objects. EDIT 1 > without needing to use boxed trait objects [...] `anyhow` It's my understanding that anyhow uses trait objects. The first sentence of its document says "This library provides anyhow::Error, a trait object based error type" EDIT 1 END > which have nice downcasting API I'm not a fan of downcasting when not _absolutely_ necessary. See my sibling comment about SNAFU for an alternate that allows keeping different errors separate while unifying them.
- dljsjr 5y agoSure but I’ve also looked at projects that have 15-20 enums (or more) for their error types and it makes things very cumbersome. `anyhow` makes it painless to have arbitrary errors. It’s trying to take the context and stuff it in to the type system. Which isn’t necessarily a bad idea but it can become unwieldy. I’ve done both in production projects and they both have their merits but 9 out of 10 times I’ll start with `anyhow` if I’m writing library code and then refactor to enums later if I need it instead of the other way around. EDIT: That said Snafu does look really cool for the cases where you do have a codified sum type of all possible errors. DOUBLE EDIT: I already had Snafu starred on GH lol
- shepmaster 5y ago> 15-20 enums (or more) for their error types and it makes things very cumbersome Like everything, it can be a balancing act. I tend to be free about creating error types (one per module, usually, but it's not strange for me to create more). When you implement `std::error::Error` (to be able to make error trait objects) and have a tool like SNAFU (to make error enums), then having more error types isn't much of a hindrance in my experience. > I’ll start with `anyhow` if I’m writing library code and then refactor to enums later The newest (beta) version of SNAFU actually strives to make this case even smoother. There's a `whatever!` macro that you can use to create stringly-typed errors and then migrate case-by-case to an enum. Check out the docs for some example usage.
- shepmaster 5y agoI'll hype my own library, SNAFU [1]. It simplifies constructing your own "leaf" errors and streamlines the ability of collecting multiple types of errors while attaching more context to them (e.g. filenames, stack traces, user ids, etc.). It allows you to smoothly switch from "stringly-typed" errors to strongly-typed errors. You can create opaque errors to avoid leaking internal implementation details into your public API. Applied to the code in the post: use snafu::prelude::*; use std::{fs::File, io::prelude::*}; #[derive(Debug, Snafu)] enum Error { #[snafu(display("Unable to open {filename}"))] Opening { source: std::io::Error, filename: String, }, #[snafu(display("Unable to read {filename}"))] Reading { source: std::io::Error, filename: String, }, #[snafu(display("Unable to parse {buffer} as a number"))] Parsing { source: std::num::ParseIntError, buffer: String, }, } fn read_number_from_file(filename: &str) -> Result<u64, Error> { let mut file = File::open(filename).context(OpeningSnafu { filename })?; let mut buffer = String::new(); file.read_to_string(&mut buffer) .context(ReadingSnafu { filename })?; let buffer = buffer.trim(); let parsed: u64 = buffer.parse().context(ParsingSnafu { buffer })?; Ok(parsed) } The key parts are the `derive(Snafu)` on the definition of the error enum and the usages of `.context` and `XxxSnafu` at the error sites. Importantly, this example demonstrates a key feature of SNAFU, here shown as "not all `io::Error`s are the same". Opening the file and reading the file are two separate error conditions and should not be lumped together as one. [1]: https://docs.rs/snafu/0.7.0-beta.1/snafu/index.html https://docs.rs/snafu/0.7.0-beta.1/snafu/index.html
- dmix 5y agoKinda feels like putting types in the comments like JSDoc or Dialyzer. I don't use Rust enough to comment otherwise.
- db48x 5y agoIt’s a little similar, except that these are attributes rather than comments. They’re part of the syntax of the language, and new macros can be written to add new attributes. Attributes are used for several different purposes within the language, so using them is familiar and common. Rust also has two kinds of comments, one that shows up in the generated documentation and one that doesn’t.
- quotemstr 5y agoMore hacks upon hacks to make up for the glaring inability of the Rust people to acknowledge they made a mistake by leaving out exceptions. Just like the "type after identifier" thing, the use of error values instead of exceptions was a pointless piece of Go envy in fashion at the time that we're all going to have to live with for decades. Plus, Rust doesn't even get to avoid paying for exceptions, because it still has panics and still has to support unwind semantics --- even though they can be disabled at build time. Avoiding exceptions was a needless technical error and an illustration of why we should design systems around tried and true techniques instead of jumping on fashion trends and ossifying those fashion trends into immutable mediocrity.
- shepmaster 5y agoWhich language(s) did exceptions the best, in your opinion?
- bartwe 5y agoI'd say c#, though the lack of a hard 'nothrow' constraint is annoying.
- fouric 5y agoNot OP, but: I think that there's a pretty strong case to be made that conditions, implemented in Common Lisp, are the best implementation of "exceptions"...by not actually being exceptions. Conditions (plus restarts) have two advantages over exceptions (as implemented in almost every other language, including Go and C#) and Rust's error system that neither can replicate: they allow you to selectively keep the stack wound (both avoiding expensive recomputation and keeping error context), and they separate out multiple error recovery strategies from each other, and allow you to pick which one you want at runtime. The latter fixes a core problem of all other error-handling systems: that the high-level code usually has the context to determine why a low-level operation was happening, and therefore how the error should be recovered from - but it doesn't (or shouldn't) have knowledge of what low-level operations are happening, because that breaks encapsulation. Meanwhile, the low-level code obviously knows about itself - but it doesn't have the high-level context necessary to determine which of several error-recovery strategies to take. A condition system exposes multiple named restarts, which are error-recovery strategies, at the low-level code (technically, you can place them everywhere), and then the higher-level code can choose among those restarts (with names like "abort", "retry", "continue", "ignore") when an error occurs, based on the high-level context and the condition type (because conditions, like exceptions, also have types). Note that these restarts do not necessarily unwind the stack. The stack can remain "wound" to the point of the error, or it can be partially unwound if a restart a few frames up is selected. Exceptions allow none of this - the moment an exception (or a Rust error type) is thrown/returned, the stack is unwound up to the error-handling point, and all context not explicitly encoded is lost (as well as the possibility of retrying an operation (perhaps with different parameters)). Short example: let's say you're writing parser for a log format that goes line-by-line. There's the high-level "read-log-file" function, that calls several other functions that eventually calls a "parse-log-file-line" function. From the local perspective of just that low-level function, if it's fed a line it can't parse, there are multiple valid error-recovery strategies: it could try to repair the line as best as it could, it could add the corrupted line to a list, it could skip the line, or it could explode and die. With a normal exception system, you have to either thread a dedicated argument specifying the strategy down to that function, hard-code it to pick a particular strategy, or re-write that function for your particular application. With conditions, the parse-log-file-line function could throw a condition INVALID-LINE and expose each of the previous options as restarts, and the high-level code could pick one of them, or even handle the condition itself! Longer examples: https://lisper.in/restarts https://lisper.in/restarts https://gigamonkeys.com/book/beyond-exception-handling-conditions-and-restarts.html https://gigamonkeys.com/book/beyond-exception-handling-condi...
- shaoner 5y agoI faced the same kind of issue lately and thought that implementing a From trait for each type of error was kind of annoying. Taking the article example, I ended up doing this: #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum MyError { MyErr1, MyErr2, MyErr3, } fn read_number_from_file(filename: &str) -> Result<u64, MyError> { let mut file = File::open(filename).or(Err(MyError::MyErr1))?; // Error! let mut buffer = String::new(); file.read_to_string(&mut buffer).or(Err(MyError::MyErr2))?; // Error let parsed: u64 = buffer.trim().parse().or(Err(MyError::MyErr3))?; // Error Ok(parsed) } As I'm a beginner, I would love to hear some thoughts on this
- loeg 5y agoThe downside of this approach is that you have discarded the original error information. It’s also somewhat verbose at the error-handling site.
- sfvisser 5y agoNothing wrong with that, although you could use ‘map_err’ instead of ‘or’ to preserve the original errors as well if you want to.
- onei 5y agoI really like the thiserror crate for cutting down boilerplate and you can add annotations to automatically implement From. However, if you want to handle errors with more granularity, e.g. IO errors as noted elsewhere in the discussion, then you'll need to implement From yourself. And as others noted, .map_err(...) is slightly better than .or(...) for wrapping errors.
- hota_mazi 5y agoThe only downside of this approach is that you can't pass a dynamic string giving details on the error, but that's because Rust's enums are not powerful enough to express this (as opposed to Kotlin or Java).
- c7DJTLrn 5y agoError handling in Rust is one of the reasons I went back to Go. It's a lot of mental overhead having to constantly think about all the different types of errors from different crates that could be returned and how to handle them at the caller. I don't want to have to import somebody's hobby crate that claims to make it easier either. In Go it's just dead simple. I've noticed a lot of Rust discussion is on how to write the code or make the compiler happy which is quite telling. I don't want to spend my time thinking about how to write the code and make the compiler happy - I want to get a problem solved and a ticket closed. Hence why I went back to Go (but I acknowledge these languages have distinct goals).
- TheDong 5y agoI've found that Go has significantly more mental overhead when it comes to error handling than rust does. In Go, I don't have type information to know what kind of error it is. In rust, I can typically just "match err", and let the compiler tell me what error types it could be, and then handle each of them. In go, I always have to read the entire function that returns the error, and any functions it calls, and so on, and then I have to think about how to handle it. Is it an error from the os package, like "os.Open"? Well, maybe I need: if os.IsNotExist(err) { /* handle */ } Is it an error from the net package? I might need: var addrErr *net.AddrError var invalidAddrErr net.InvalidAddrError var otherErr net.Error if errors.As(err, &addrErr) { /* handle addrErr */ } else if errors.As(err, &invalidAddrErr) { /* handle invalidAddrErr */ } else if errors.As(err, &otherErr) { /* some other net error */ } else if errors.Is(err, net.ErrClosed) { /* handle this network error which is _not_ a net.Error */ } Is it an error that used one of the third party error libraries, like "github.com/hashicorp/errwrap"? I may need even more special handling. The fact that "errors.Is" and "errors.As" requires me to know if an error is a "sentinel" one like 'net.ErrClosed', or a struct error, like 'os.PathError', and I have to know if the struct is implementing error on a pointer receiver or not is also really annoying since it means I have to constantly go check error definitions, even if I vaguely remember what errors a function returns. And, what's worse, the type system doesn't help me! It won't tell me if I get any of these things wrong. I can use "errors.Is" and "errors.As" backwards, and the type system won't help. I have no way to determine what errors a function can return without reading hundreds of lines of code. Frankly, Go's error handling has more overhead than almost any other language, and I find it surprising you find it simple. If all I'm doing is bubbling errors up, then rust's "?" operator works fine, and is easier than Go's "if err != nil" boilerplate. If I'm handling errors, rust's features are helpful, and Go is so much mental overhead it usually makes me forget the problem I was trying to solve by the time I've drawn out the full diagram of possible errors something can return up the stack. Usually rust's type-system has enough info that I don't need to read any code to know what errors I need to handle. That's never the case in Go, because idiomatically errors are "type-erased" into the error interface. Oh, and this is all not to mention that go facilitates making unhandle-able errors. If someone returns "errors.New" or "fmt.Errorf", you're stuck with string matching, while rust encourages making typed and handle-able errors. The number of times I've had my go code break because someone reworded an un-exported error string I had to match on is pretty high. Hasn't happened to me in rust yet.
- schneems 5y agoErrors in rust are one of the hardest things for me to wrap my head around as a beginner. Thanks for this post! I also really like this blog post that also walks you through the history of some features like the try! Macro and shows how they are implemented https://blog.burntsushi.net/rust-error-handling/ https://blog.burntsushi.net/rust-error-handling/
- ejanus 5y agoBeyond Borrow checker, move/copy, and error propagation(different error in fn), what else should someone learn in order to have basic understanding of Rust?
- ragnese 5y agoSomething worth mentioning is that your error types do NOT have to implement std::error::Error. The type parameters on Result<T, E> have no constraints. So, only your public API should implement std::error::Error. Likewise, you don't HAVE TO implement From for all of your error types. In fact, it has some of the same concerns that implicit constructors in C++ have in that it's easy to just throw try-operators around without thinking about whether you actually want to bubble up an error in a specific scenario. I think a lot of people who pick up Rust might not realize that and end up thinking the error defining process is even more tedious than it really is.