15 ms·
Error handling in Rust
- jgilias 1y agoYeah… Please no. I’m getting a bit of a macro fatigue in Rust. In my humble opinion the less “magic” you use in the codebase, the better. Error enums are fine. You can make them as fine-grained as makes sense in your codebase, and they end up representing a kind of an error tree. I much prefer this easy to grok way to what’s described in the article. I mean, there’s enough things to think about in the codebase, I don’t want to spend mental energy on thinking about a fancy way to represent errors.
- Waterluvian 1y agoAgreed about magic. Please correct me if I’m misunderstanding this, but something that surprised me about Rust was how there wasn’t a guaranteed “paper trail” for symbols found in a file. Like in TypeScript or Python, if I see “Foo” I should 100% expect to see “Foo” either defined or imported in that specific file. So I can always just “walk the paper trail” to understand where something comes from. Or I think there was also a concept of a preamble import? Where just by importing it, built-ins and/or other things would gain additional associated functions or whatnot. In general I just really don’t like the “magic” of things being within scope or added to other things in a manner that it’s not obvious. (I’d love to learn that I’m just doing it wrong and none of this is actually how it works in Rust)
- jgilias 1y agoYou can import everything from a module with a *, but most people seem to prefer to import things explicitly. But, yes, you can generally figure out easily where things are coming from!
- itishappy 1y agoDoesn't something like the following break the trail in pretty much all languages? from package import *
- Waterluvian 1y agoYup. And I’ve not seen that used in forever and it’s often considered a linting error because it is so nasty. So maybe what I’m remembering about Rust was just seeing a possible but bad convention that’s not really used much.
- 57473m3n7Fur7h3 1y agoIt’s a bit confusing sometimes with macros that create types that don’t seem to exist. But usually when I work with code I use an IDE anyway and “go to definition” will bring me to where it’s defined, even when it’s via a macro. Still generally prefer the plain non-macro declarations for structs and enums though because I can easily read them at a glance, unlike when “go to definition” brings me to some macro thing.
- burnt-resistor 1y agoYes. Macros are a hammer, but not everything is a nail. Declarative macros (macro_rules) should be used to straightforwardly reduce repetitive, boilerplate code generation and making complex, messy things simpler. Procedural macros (proc_macro) allow creating arbitrary, "unhygienic" code that declarative macros forbid and also custom derive macros and such. But it all breaks down when use of a library depends too much on magic code generation that cannot be inspected. And now we're back to dynamic language (Ruby/Python/JS) land with opaque, tinkering-hostile codebases that have baked-in complexity and side-effects. Use magic where appropriate, but not too much of it, is often the balance that's needed.
- quotemstr 1y ago> Yes. Macros are a hammer, but not everything is a nail. Overuse of macros is a symptom of missing language capabilities. My biggest disappointment in Rust (and probably my least popular opinion) is how Rust botched error handling. I think non-local flow control (i.e. exceptions) with automated causal chaining (like Python) is a good language design point and I think Rust departed from this good design point prematurely in a way that's damaged the language in unfixable ways. IOW, Rust should have had _only_ panics, and panic objects should have had rich contextual information, just like Java and Python. There should also have been an enforced "does not panic" annotation like noexcept in C++. And Drop implementations should not be allowed to panic. Ever. God, I hope at least yeet gets in.
- zbentley 1y ago> Rust should have had _only_ panics, and panic objects should have had rich contextual information, just like Java and Python. It could have gone that way, but that would have “fattened” the runtime and overhead of many operations, making rust unsuitable for some low-overhead-needed contexts that it chose to target as use-cases. More directly: debug and stack info being tracked on each frame has a cost (as it does in Java and many others). So does reassembling that info by taking out locks and probing around the stack to reassemble a stack trace (C++). Whether you agree with Rust’s decision to try to serve those low-overhead niches or not, that (as I understand it) is a big part of the reason for why errors work the way they do. > There should also have been an enforced "does not panic" annotation like noexcept in C++. And Drop implementations should not be allowed to panic. I sometimes think that I’d really love “nopanic”. Then I consider everything that could panic (e.g. allocating) and I like it less. I think that heavy use of such a feature would lead to people just giving up and calling abort() in library code in order to be nopanic-compatible, which is an objectively worse outcome than what we have today.
- throwaway894345 1y agoBoth sides have been a pain for me. Either I’m debugging macro errors or else I’m writing boilerplate trait impls all day… It feels like a lose/lose. I have yet to find a programming language that does errors well. :/
- dingi 1y agoAnnotations were once condemned as 'magic' for doing things at runtime. Now it's apparently fine to use a language where most non-trivial code depends on macros. Tools that rewrite your code at compile time, often invisibly. But hey, it's not magic if it's your magic, right?
- fwip 1y agoThis looks nice, especially for a mature/core library. If your API already maps to orthogonal sets of errors, or if it's in active development/iteration, you might not get much value from this. But good & specific error types are great documentation for helping developers understand "what can go wrong," and the effects compound with layers of abstraction.
- jgilias 1y agoIs it really though? What’s the point of having an error type per function? As the user of std::io, I don’t particularly care if file couldn’t be read in function foo, bar, or baz, I just care that the file couldn’t be read.
- dwattttt 1y agoAn error type per function doubles as documentation. If you treat all errors as the same it doesn't matter, but if you have to handle some, then you really care about what actual errors a function can return.
- jgilias 1y agoOk, that’s a valid point! Though there’s a trade-off there, right? If both bar and baz can not find a file, they’re both going to return their own FileNotFound error type. And then, if you care about handling files not being found somewhere up the stack, don’t you now have to care about two error types that both represent the same failure scenario?
- Groxx 1y agoif you have multiple files that are read in a function, and they might lead to different error handling... then sometimes yeah, perhaps they should be different types so you are guaranteed to know that this is a possibility, and can choose to do something specific when X happens. or to be informed if the library adds another file to the mix. it isn't always the case, of course, but it also isn't always NOT the case.
- burnt-resistor 1y agoTo skip recreating OOP in Rust, use anyhow instead.
- cute_boi 1y agoRust should seriously stop using macros for everything.
- kaashif 1y agoI agree with this. It appears that in this case, macros are a band-aid over missing features in the type system. I feel like structural typing or anonymous sum types would solve this problem without macros. I mean given A = X | Y and B = X | Y | Z, surely the compiler can tell that every A is a B?
- estebank 1y agoThis has problems as soon as it hits generics and lifetimes. That being said, I think a limited version of the feature that disallows the the same type with divergent lifetimes or even all generics would still be useful and well liked.
- nixpulvis 1y agoWhat's wrong with macros? Especially attribute style macros which apply to variants of an enum allow a lot of code expansion which reduces boilerplate a lot.
- pjmlp 1y agoIncrease of compile times, on a already blazing fast compiler. Debugging, especially when it lacks the tooling of a Lisp or Scheme REPL/IDE.
- slau 1y agoI disagree that the status quo is “one error per module or per library”. I create one error type per function/action. I discovered this here on HN after an article I cannot find right now was posted. This means that each function only cares about its own error, and how to generate it. And doesn’t require macros. Just thiserror.
- thrance 1y agoThat's the way, but I find it quite painful at time. Adding a new error variant to a function means I now have to travel up the hierarchy of its callers to handle it or add it to their error set as well.
- mdaniel 1y agoSometimes that error was being smuggled in another broader error to begin with, so if the caller is having to go spelunking into the .description (or .message) to know, that's a very serious problem. The distinction I make is: if the caller knew about this new type of error, what would they do differently?
- slau 1y agoThis shouldn’t happen unless you’re actively refactoring the function and introducing new error paths. Therefore, it is to be expected that the cake hierarchy would be affected. You would most likely have had to navigate up and down the caller chain regardless of how you scope errors. At least this way the compiler tells you when you forgot to handle a new error case, and where.
- layer8 1y agoThis is eerily reminiscent of the discussions about Java’s checked exceptions circa 25 years ago.
- mananaysiempre 1y agoThe difference is that Java does not have polymorphism for exception sets (I think? certainly it didn’t for anything back then), so you couldn’t even type e.g. a callback-invoking function properly. Otherwise, yes, as well as the discussions about monad transformers in Haskell from 15 years ago. Effects are effects are effects.
- Animats 1y agoIt's hard. Python 2.x had a good error exception hierarchy, which made it possible to sort out transient errors (network, remote HTTP, etc.) errors from errors not worth retrying. Python 3 refactored the error hierarchy, and it got worse from the recovery perspective, but better from a taxonomy perspective. Rust probably should have had a set of standard error traits one could specialize, but Rust is not a good language for what's really an object hierarchy. Error handling came late to Rust. It was years before "?" and "anyhow". "Result" was a really good idea, but "Error" doesn't do enough.
- efnx 1y ago`Result` is just `Either` by another name, and the main idea is to use sum and product types as the result of a computation instead of throwing, which turns error handling into business as usual. The `Result` type and `Error` trait really are orthogonal, and as soon as the `Try` trait is stabilized I think we'll see some good improvements.
- _benton 1y agoI really wish Rust had proper union types. So much ceremony over something that could be Foo | Bar | Error
- rtpg 1y agoI've thought about this a good amount too because Typescript gets so much out of untagged unions but I think that with a language like Rust you get into a huge mess due to it messing up inference and also Rust _really_ wanting to know the size of a type most of the time. let mut x = 1 x = false is x a usize? a bool? a usize | bool? let mut x = if some_condition { 1 } else { false } is x x a usize? a bool? a usize | bool? One could make inference rules about never inserting unions without explicit intervention of user types. But then you get into (IMO) some messy things downstream of Rust's pervasive "everything is an expression" philosophy. This is less of a problem in Typescript because you are, generally, much less likely to have conditionally typed expressions. There's a ternary operator, but things like `switch` are a statement. So in production code Typescript, when presented with branching, will have a downstream explicit type to unify on. Not so much in Rust's extensive type chaining IMO. And then we start talking about Into/From and friends.... I don't really think that you want a rule like `(if cond { x: T } else { y: U}) : T | U` in general. You'll end up with _so many_ false negatives and type errors at a distance. But if you _don't_ have that rule, then I don't know how easily your error type unification would work.
- pjmlp 1y agoThis is the kind of stuff I would rather not have outsourced for 3rd party dependencies. Every Rust project starts by looking into 3rd party libraries for error handling and async runtimes.
- wongarsu 1y agoOr rather every rust project starts with cargo install tokio thiserror anyhow. If we just added what 95% of projects are using to the standard library then the async runtime would be tokio, and error handling would be thiserror for making error types and anyhow for error handling. Your ability to go look for new 3rd party libraries, as well as this article's recommendations, are examples of how Rust's careful approach to standard library additions allows the ecosystem to innovate and try to come up with new and better solutions that might not be API compatible with the status quo
- johnisgood 1y agoJust please let us not end up with something like Node.js where we use a crate that has <10 LOC. Irks me. And if Rust ends up like that, I will never switch. It already builds ~50 crates for medium-sized projects.
- Expurple 1y agoRust crates usually have "feature flags" [1] that help with dependency bloat. You only pull in the parts of the dependency tree that you actually use, even if the crates that you use can be configured to provide more features and pull more dependencies. https://doc.rust-lang.org/cargo/reference/features.html https://doc.rust-lang.org/cargo/reference/features.html
- pjmlp 1y agoAlso a reason why some crates get compiled multiple times, making the compile times even more of a pain, when they happen to be referenced multiple times with different configurations.
- drewlesueur 1y agoIs it just me or is the margin/padding altered. I notice this article (being first) is squished up against the orange header bar
- IshKebab 1y agoKind of reminds me of Java checked exceptions.
- vmaurin 1y agoThe cyclic pattern of IT. New language, same problems, same mistakes, same solutions found (but losing years again)
- IshKebab 1y agoNo, I said it reminds me of them. It definitely avoids some of the flaws of exceptions. Not the same solution.
- Expurple 1y agoIt's kinda similar, but Rust also solves the problems of checked exceptions: https://home.expurple.me/posts/rust-solves-the-issues-with-exceptions/#checked-exceptions-in-java https://home.expurple.me/posts/rust-solves-the-issues-with-e...
- metaltyphoon 1y ago> The current standard for error handling, when writing a crate, is to define one error enum per module… Excuse me what? > This means, that a function will return an error enum, containing error variants that the function cannot even produce. The same problem happens with exceptions.
- the__alchemist 1y agoLately, I've been using io::Error for so many things. (When I'm on std). It feels like everything on my project that has an error that I could semantically justify as I/O. Usually it's ErrorKind::InvalidData, even more specifically. Maybe due to doing a lot of file and wire protocol/USB-serial work? On no_std, I've been doing something like the author describes: Single enum error type; keeps things simple, without losing specificity, due the variants. When I need to parse a utf-8 error or something, I use .map_err(|_| ...) After reading the other comments in this thread, it sounds like I'm the target audience for `anyhow`, and I should use that instead.
- benreesman 1y agoThe best parts of Rust are Haskell. You've got a lot of precedent for how you do it.
- nixpulvis 1y agoI think I read somewhere that anyhow is great for application code where you want a unified error type across the application. And something like thiserror is good for library code where you want specific error variants for each kind of fallibility. Personally, I think I prefer thiserror style errors everywhere, but I can see some of the tradeoffs.
- stephenlf 1y agoI apologize for this sidebar. I don’t have much to contribute to the technical content. It was an interesting read. You use, too many, commas, in your, writing. It’s okay to have a long sentence with multiple phrases. Thanks for sharing your thoughts.
- xixixao 1y agoI find TS philosophy of requiring input types and inferring return types (something I was initially quite sceptical about when Flow was adopting it) quite nice to work with in practice - the same could be applied to strict typing of errors ala Effect.js? This does add the “complexity” of there being places (crate boundaries in Rust) where you want types explicitly defined (so to infer types in one crate doesn’t require typechecking all its dependencies). TS can generate these types, and really ought to be able to check invariants on them like “no implicit any”. Rust of course has difference constraints and hails more from Haskell’s heritage where the declared return types can impact runtime behavior instead. I find this makes Rust code harder to read unfortunately, and would avoid it if I could in Rust (it’s hard given the ecosystem and stdlib).
- xixixao 1y agoThis already does work in TS, and there are some patterns besides Effect that simplify working with the return values. Which brings me to my other big gripe with Rust (and Go): the need to declare structs makes it really unwieldy to return many values (resorting to tuples, which make code more error prone and again harder to read).
- _benton 1y agoYep. I wish Rust supported proper union types. Typescript gets it right, I just don't want to be writing Javascript...
- estebank 1y agoFun fact: the compiler itself has some limited inference abilities for return types, they are just not exposed to the language: https://play.rust-lang.org/?version=nightly&mode=debug&edition=2024&gist=5a02b067e7b9a3d0f192d9b9a319f620 https://play.rust-lang.org/?version=nightly&mode=debug&editi... I have some desire to make an RFC for limited cross-item inference within a single crate, but part of it wouldn't be needed with stabilized impl Trait in more positions. For public items I don't think the language will ever allow it, not only due to technical concerns (not wanting global inference causing compile times to explode) but also language design concerns (inferred return types would be a very big footgun around API stability for crate owners).
- kshri24 1y ago> And so everyone and their mother is building big error types. Well, not Everyone. A small handful of indomitable nerds still holds out against the standard. The author is a fan of Asterix I see :)
- forrestthewoods 1y agoI quite like the Rust approach of Result and Option. The anyhow and thiserror crate are pretty good. But yeah I constantly get confused by when errors can and can not coerce. It's confusing and surprising and I still run into random situations I can't make heads or tails from. I don't know what the solution is. And Rust is definitely a lot better than C++ or Go. But it also hasn't hit the secret sauce final solution imho.
- Expurple 1y ago> But yeah I constantly get confused by when errors can and can not coerce. It's simple, really. `?` coerces errors if there's an `impl From<InnerError> for OuterError`: fn outer() -> Result<(), OuterError> { inner()?; Ok(()) } When OuterError is your own type, you can always add that impl. When it's a library type, you're at its mercy. E.g., the point of anyhow::Error is that it's designed to automatically wrap any other error. To do that, anyhow provides an impl<E> From<E> for anyhow::Error where E: Error + Send + Sync + 'static
- forrestthewoods 1y agoI regularly get super confused by what coerces and what doesn’t. I swear there’s cases where ? works but into() does not. It’s many things. But simple ain’t one of them =D
- Expurple 1y agoI'm not aware of such cases. And The Rust Reference says [1]: > If the value is Err(e), then it will return Err(From::from(e)) from the enclosing function or closure. Without a specific example, I can't help you further. [1]: https://doc.rust-lang.org/stable/reference/expressions/operator-expr.html#r-expr.try.effects-err https://doc.rust-lang.org/stable/reference/expressions/opera...
- jppittma 1y agoI don’t really agree with this. The vast majority of the time, if you encounter an error at runtime, there’s not much you can do about it, but log it and try again. From there, it becomes about bubbling the error up until you have the context to do that. Having to handle bespoke error type from different libraries is actually infuriating, and people thinking this is a good idea makes anyhow mandatory for development in the language.
- devnullbrain 1y ago>This means, that a function will return an error enum, containing error variants that the function cannot even produce. If you match on this error enum, you will have to manually distinguish which of those variants are not applicable in your current scope You have to anyway. The return type isn't to define what error variants the function can return. We already have something for that, it's called the function body. If we only wanted to specify the variants that could be returned, we wouldn't need to specify anything at all: the compiler could work it out. No. The point of the function signature is the interface for the calling function. If that function sees an error type with foo and bar and baz variants, it should have code paths for all of them. It's not right to say that the function cannot produce them, only that it doesn't currently produce them.
- deleted 1y ago[deleted]
- andrewmcwatters 1y agoGo got this right. Lua also has a nice error mechanism that I haven't seen elsewhere where you can explicitly state where in the call stack the error is occurring (did I error from the caller? or the callee?). Similarly, JavaScript seems to do OK, but I miss error levels. And C seems to also have OK error conventions that aren't too bad. There's a handful of them, and they're pretty uncontroversial. Macros seem to be wrong in every language they're used, because people can't help themselves. It's like a red flag that the language designers were OK giving you enough rope to hang yourself with, but also actively encourage you to kill yourself because why else would you use the rope for anything else?
- Expurple 1y agoNo, Go didn't get this right. Returning a tuple (a T and an errror) isn't an appropriate tool when you want your function to return either a T or an errror. It's a brittle hack that reqires everyone to use a third-party linter on top. Otherwise that tuple is handled incorrectly too frequently. All of that, because Go keeps ignoring a basic feature from the 1970s [1] that allows to you express the "or" relationships (and nullability). APIs that are easy to use incorrectly are bad APIs. [1]: https://en.wikipedia.org/wiki/Tagged_union#1970s_&_1980s https://en.wikipedia.org/wiki/Tagged_union#1970s_&_1980s
- Expurple 1y agoI've even decided to turn my parent comment into a blog post [1]. It also fixes numerous typos, so qoute the blog version. [1] https://home.expurple.me/posts/go-did-not-get-error-handling-right/ https://home.expurple.me/posts/go-did-not-get-error-handling...
- andrewmcwatters 1y agoI’m honored you disagree with me enough to write your thoughts on the matter. Thank you for your insight.
- 1y ago
- unit149 1y ago[dead]
- tayo42 1y agoThis article and comment section are making me feel like one of the only people that like error handling in Rust? I usually use an error for the crate or application with an enum of types. Maybe a more specific error if it makes sense. I don't even use anyhow or this error. I like it better then python and go.
- nixpulvis 1y agoAs a bit of an aside, I get pretty far just rolling errors by hand. Variants fall into two categories, wrappers of an underlying error type, or leafs which are unique to my application. For example, enum ConfigError { Io(io::Error), Parse { line: usize, col: usize }, ... } You could argue it would be better to have a ParserError type and wrap that, and I absolutely might do that too, but they are roughly the same and that's the point. Move the abstraction into their appropriate module as the complexity requests it. Pretty much any error crate just makes this easier and helps implement quality `Display` and other standard traits for these types.
- echelon 1y ago> I get pretty far just rolling errors by hand And you don't punish your compile times. The macro for everything folks are making Rust slow. If we tire of repetition, I'd honestly prefer checked in code gen. At least we won't repeatedly pay the penalty.
- Expurple 1y ago> At least we won't repeatedly pay the penalty. Another promising solution for that is caching proc macro expansions in the compiler. "How I reduced (incremental) Rust compile times by up to 40%": https://web.archive.org/web/20250311042037/https://www.coderemote.dev/blog/faster-rust-compiler-macro-expansion-caching/ https://web.archive.org/web/20250311042037/https://www.coder...
- mparis 1y agoI'm a recent snafu (https://docs.rs/snafu/latest/snafu/ https://docs.rs/snafu/latest/snafu/) convert over thiserror (https://docs.rs/thiserror/latest/thiserror/ https://docs.rs/thiserror/latest/thiserror/). You pay the cost of adding `context` calls at error sites but it leads to great error propagation and enables multiple error variants that reference the same source error type which I always had issues with in `thiserror`. No dogma. If you want an error per module that seems like a good way to start, but for complex cases where you want to break an error down more, we'll often have an error type per function/struct/trait.
- shepmaster 1y agoThanks for using SNAFU! Any feedback you'd like to share?
- Expurple 1y ago> multiple error variants that reference the same source error type which I always had issues with in `thiserror`. Huh? #[derive(Debug, thiserror::Error)] enum CustomError { #[error("failed to open a: {0}")] A(std::io::Error), #[error("failed to open b: {0}")] B(std::io::Error), } fn main() -> Result<(), CustomError> { std::fs::read_to_string("a").map_err(CustomError::A)?; std::fs::read_to_string("b").map_err(CustomError::B)?; Ok(()) } If I understand correctly, the main feature of snafu is "merely" reducing the boilerplace when adding context: low_level_result.context(ErrorWithContextSnafu { context })?; // vs low_level_result.map_err(|err| ErrorWithContext { err, context })?; But to me, the win seems to small to justify the added complexity.
- shepmaster 1y agoYou certainly can use thiserror to accomplish the same goals! However, your example does a little subtle slight-of-hand that you probably didn't mean to and leaves off the enum name (or the `use` statement): low_level_result.context(ErrorWithContextSnafu { context })?; low_level_result.map_err(|err| CustomError::ErrorWithContext { err, context })?; Other small details: - You don't need to move the inner error yourself. - You don't need to use a closure, which saves a few characters. This is even true in cases where you have a reference and want to store the owned value in the error: #[derive(Debug, Snafu)] struct DemoError { source: std::io::Error, filename: PathBuf } let filename: &Path = todo!(); result.context(OpenFileSnafu { filename })?; // `context` will change `&Path` to `PathBuf` - You can choose to capture certain values implicitly, such as a source file location, a backtrace, or your own custom data (the current time, a global-ish request ID, etc.) ---- As an aside: #[error("failed to open a: {0}")] It is now discouraged to include the text of the inner error in the `Display` of the wrapping error. Including it leads to duplicated data when printing out chains of errors in a nicer / structured manner. SNAFU has a few types that work to undo this duplication, but it's better to avoid it in the first place.
- terhechte 1y agoThe error library he seems looking for is „error_mancer“