26 ms·
Error Handling Is Hard
- q3k 6y agoGood article. I've always argued that blind ?-style shortcircuiting in Rust is extreme code smell and guaranteed pain for future production outages, and that it's not an actual solution to error the handling problem (which seems to come up often in Rust/Go flamewars). And I'm glad that this idea is picking up traction. Error handling is hard, Go doesn't hide the complexity, but a lot of other languages try to handwave the complexity away. Either way, programmers are by default lazy, and you end up with "Error 500: connection refused".
- theamk 6y ago.. except in Java and Python, where you also get 100-line stack trace in your error log. Or C/C++, where you get a full coredump with program state as long as you set up your system correctly. Let's not forget there are more options than Go's and Rust's approaches. If some languages made error handling hard, it does not mean it is hard in general.
- q3k 6y agoOne of my longest and most frustrating debugging sessions was with a Java project where some developer was fed up with 100-line stack traces and wrote the following: catch (Exception e) { log.error(e); } Which caused the following error output ERROR: could not connect (mentally adjust to Java syntax, error message, don't remember off the top off my head) I'm not joking. Having overly-verbose errors by default will make people just swallow everything at some point or another. That particular example was triggered, IIRC, by a configuration file containing a malformed IP address, which was interpreted as a hostname, which resolved into an NXDOMAIN, which caused a magic connect-socket-to-dns-name-or-ip-address call to fail with a generic message when that socket was lazy-connected in an unrelated code path.
- theamk 6y agoYep, people keep doing this. I think it depends on background. If the app is to be used by user unfamiliar with language, and there are no robust error reporting systems -- think website or desktop app -- then this approach actually makes sense. But in the environment where most investigation is done via the logs, and many bugs are not trivially reproducible? Not so much.
- chaorace 6y agoFollowing through to the logical conclusion, you would want some way to switch between simple logs and context-rich logs. We've reinvented the verbose flag!
- merb 6y agobtw. there are error loggers which would print a stack trace in that case. i.e. slf4j. you can even do log.error(e, "blablabla");
- throw_m239339 6y ago> Error handling is hard, Go doesn't hide the complexity, but a lot of other languages try to handwave the complexity away. Either way, programmers are by default lazy, and you end up with "Error 500: connection refused". Except that (no pun) Go has both exceptions in the forms of panics AND its horrible error interface. I don't like exceptions but I'll take exceptions over Go glorified return codes anytime. No amount of "decorating" that system makes it better. There is nothing to it. Rust is a bit better on that matter BECAUSE Rust has the syntactic sugar to make errors as value painless to deal with. Go doesn't. Sometimes on your server, you have 15 IO operations to perform, you just want to log on failure and return error 500 to the user. This is the case where go doesn't shine at all, what do you do? aside from being tempted to resort to a GOTO statement?
- cle 6y agoYou check each one for an error and return a 500 if there's an error. It takes a few more seconds to write but is incredibly easy to read and maintain. I'd argue that this article is evidence that Rust's syntactic sugar doesn't really help, it just seems like "official" encouragement to do the wrong thing (pass the buck).
- throw_m239339 6y ago> It takes a few more seconds to write but is incredibly easy to read and maintain. I disagree. try{ dothis(); /* ... 14 lines of dothis(); like */ catch(error){ log(error); return responseCode(500); } sounds easier to both write and maintain to me. Only a handfull of times you'd ever want to deal with each exception separately. And when you do, it's not much more verbose than if err!=nil{ ... }.
- nemo1618 6y agoGranted, even though Go shoves its errors in your face, it's still common to just punt the error up the stack (if err != nil { return err }), leading to ahem "context-free" error messages like "Failed to initialize Foo: EOF". You can improve on this a little by using something like https://github.com/pkg/errors https://github.com/pkg/errors, which gives your errors a stack trace by default. But now your errors are hideous and unlikely to be fixable by the average user. Ultimately the only way to get clean, informative, and debuggable error messages is for the programmer to attend to each and every one. And no one wants to do that, because it's a pain, it slows you down, it gets in the way of solving The Problem At Hand. So unless the language itself somehow forces you to wrap all your errors in nice context strings, it ain't gonna happen.
- theamk 6y agoFor this case, I actually think Python's approach is the best: - Exceptions, so you don't need error handling - stack traces so you know the entire call tree. This is pretty important -- without stack traces, it is very hard to make sense of error messages like "Cannot open file 'none'" -- the filename is clearly wrong, but where did this come from. - chained exceptions so you can add context trivially. Doing "try: ... except: raise Exception('Cannot load config')" will print both error messages and two stack traces, so you know both original error and better description. - verbose by default so if you don't put any effort into error handling, you get lots of details. This is somewhat controversial -- when some people see large stack traces their eyes glaze over and they just stop reading. But as a program's author, I love this -- the stack traces are informative enough I can often figure out what's wrong immediately.
- Forge36 6y ago+1 on verbose by default. I've seen a lot of "error occurred" dialogs which were absolutely useless. Vs reading a verbose stack trace: trivial to back trace and say X passed this data from Y. Even for areas I wasn't an expert it. Made finding the next owner to investigate significantly easier.
- adimitrov 6y agoI really, really dislike exceptions. Unchecked exceptions don't tell the caller something might go wrong. Fine for Python, where strong guarantees aren't a thing anyway, but any statically typed language cannot be content with essentially adding bottom to every single type. Checked exceptions have failed, or at least I haven't seen anybody fix their issues. They proliferate spurious exception types in interfaces. They are inflexible, as they usually can't be generic. They suck at typing error cases for higher order functions. They're big heavy and expensive, so can't be used for hot code paths. They're exceptions but more often than not you want to signal expected failure... The list goes on...
- joseluisq 6y agoYeah, I dislike also exceptions. I'm more fan of Rust error handling for example via `Result<T, E>`. I believe that make a distinction between recoverable and unrecoverable errors is key.
- pornel 6y agoThere's also a performance trade-off. Collecting contexts and stack traces has a cost. Usually you could assume that errors are rare, so the cold path can be expensive. But not always: if you have logic such as "try A, and if it fails, try B", the error handling may suddenly be on the hot path.
- chaorace 6y agoThis is actually why I dislike Python so much. The "try first, ask forgiveness later" truism has infused itself into the core language design and surrounding community. The end result is unexceptional exceptions and the presence of a dozen "warm" paths.
- pornel 6y agoThat's not a Pythonic thing. There are whole classes of problems where you'd have time-of-check vs time-of-use (TOCTTOU) bugs/vulnerabilities if you tried to do it differently.
- chaorace 6y agoThat's the other thing I hate about Python, the concept of "Pythonic". It's literally the only language I have ever used where people will tell me my arguments are wrong because of (for lack of better words) a code of ethics. Please don't take this as an insult, but I don't care what's Pythonic. I care what the community does, and they default to failing fast (to a fault, in my own opinion).
- pornel 6y agoBy "not Pythonic" I've meant that this issue is not unique to Python. It's not a problem created by some Python dogma. It's a fundamental issue affecting all languages, even those that don't have a catchy slogan for it. In Rust and C you also need to "try first, ask forgiveness later". For example, you can never be sure whether you can open a file until you actually open the file and it doesn't fail. If you tried to check if the file exists and has correct permissions before opening it, you'd have possibility of a race condition. So the correct approach is to try the operation head first and handle the failure, instead of trying to somehow gracefully avoid creating an "exceptional" situation.
- jiehong 6y agoContext is quite important, I agree. However, when possible, giving _actionable_ info can really help, especially if the user can see that error. For example, you can try to explain why this file is expected, and give a link to the spec/doc. Extremely helpful when called at 4am in production. Alternatively, I've seen some experiments with extreme reproducibility: capture the full context (network requests, filesystem, db state, etc.), and spin up an instance with that state. This way devs can deterministically reproduce the issue, and fix it. But, it supposes that the application is written to support this fairly well (by either being very functional, or by having probes in all non-pure functions).
- q3k 6y agoYou can't always have actionable context, because generally if you could, you wouldn't throw the error in the first place. :) I mean, apart from the obvious 'database connection failed, check that it's up'. Troubleshooting hints should live in production runbooks, which tell you, for any firing alert, what is it that might cause it, how to investigate further, where the code lives, etc.
- chaorace 6y agoI assume this is possible in Rust, so someone please help me understand: why not just print the stack trace? In my opinion, if you suffer a sufficiently fatal error, you should just crash outright and log the trace + error message. Barring that luxury, you should log the trace + error message, then attempt recovery. That seems to be what unwrap here would do on its own, so why would that be bad practice (assuming the error was fatal in the first place?). Am I just misunderstanding something fundamental?
- timw4mail 6y agoNot every error is fatal, and unwrap can make trivial errors fatal.
- chaorace 6y agoI'm willing to accept that argument for unwrap, makes sense if you're dealing with a situation where multiple errors can apply. Outside of unwrap, which actually crashes out, is it not possible to access the stack trace? Forgive my ignorance, I've never worked in Rust nor any other on-the-metal languages before!
- timw4mail 6y agoIn Rust there generally isn't a stack trace, because you generally can know where the error comes from. What you get for an error depends on what is giving you the error. Generally you get an enum with the general error type, and maybe a way to access the specific error.
- chaorace 6y ago> In Rust there generally isn't a stack trace, because you generally can know where the error comes from. I don't understand. Does that mean that there's sometimes a stack trace? Why would it matter if you had other ways to know where the error comes from? Surely you could say the same thing for the majority of programming languages. Sure, Rust can wrap errors via the type system, but I don't think that's a compiler-level guarantee. Maybe it's just a language design decision, then? > Generally you get an enum with the general error type, and maybe a way to access the specific error. That seems odd to me! Is the presence of a pointer to an error object something that's implementation-specific? If so, is it not typically done in the standard library? As far as you can speculate, what's the design justification for that?
- shepmaster 6y agoAs the author of [SNAFU], another error handling library, this is how I'd encourage it: use snafu::{Snafu, ResultExt}; #[derive(Debug, Snafu)] enum Error { #[snafu(display("Failed to read {}", filename))] ReadInput { filename: String, source: std::io::Error }, } type Result<T, E = Error> = std::result::Result<T, E>; fn main() -> Result<()> { let filename = "input.txt"; let s = std::fs::read_to_string(filename).context(ReadInput { filename })?; println!("{}", s); Ok(()) } Unfortunately, this uses the default `Debug` formatting, so what is printed to the user in this `main` example is still less-than-ideal: Error: ReadInput { filename: "input.txt", source: Os { code: 2, kind: NotFound, message: "No such file or directory" } } I encourage using the `Display` formatter or something more complete. I find that doing this rigorously creates what I call a "semantic backtrace", where the linked list of (error, context, underlying cause) provides a great deal of insight into the problem. The next release of SNAFU will incorporate something that allows for stringly-typed errors: use snafu::{ResultExt, Whatever}; type Result<T, E = Whatever> = std::result::Result<T, E>; fn main() -> Result<()> { let filename = "input.txt"; let s = std::fs::read_to_string(filename) .with_whatever_context(|_| format!("couldn't read {}", filename))?; println!("{}", s); Ok(()) } You will also be able to combine stringly-typed errors with the structured errors. Other features of the currently-released SNAFU: - Custom error types - Backtraces - Extension traits for Results / Options / Futures / Streams - Suitable for libraries and applications - no-std compatibility - Generic types and lifetimes [SNAFU]: https://crates.io/crates/snafu https://crates.io/crates/snafu
- q3k 6y agoHow does this work if I use SNAFU and so does a library I call?
- shepmaster 6y agoI'm not 100% sure what you mean, so I'll try to cover it broadly. Ideally the fact that you use SNAFU should not escape from your library; I encourage creating an [opaque] error in most cases. The current deliberate "leak" is that we implement a [trait] that exposes the ability to get a backtrace from a SNAFU error because the standard library has not yet stabilized backtraces. Once those are stabilized, we should be able to completely isolate and only operate through the `std::error::Error` trait. [opaque]: https://docs.rs/snafu/0.6.9/snafu/guide/opaque/index.html https://docs.rs/snafu/0.6.9/snafu/guide/opaque/index.html [trait]: https://docs.rs/snafu/0.6.9/snafu/trait.ErrorCompat.html https://docs.rs/snafu/0.6.9/snafu/trait.ErrorCompat.html
- kbyatnal 6y agoIMO Erlang/Elixir does it best with the "let it crash" mentality. Once you internalize and truly understand how it works, you won't want to do error handling in any other way. The tldr; handle errors you can handle, let everything else crash and rely on the runtime to bring you back online. The zen of erlang (https://ferd.ca/the-zen-of-erlang.html https://ferd.ca/the-zen-of-erlang.html) goes more in depth if you're curious.
- steveklabnik 6y agoThe conflict here is situations, like in Rust, where you don't have that runtime in the first place, so you cannot do this. Rust used to be much more Erlang-like in this regard back in the days when it had such a runtime, but when the runtime was removed, so was the ability to work like this.
- johnisgood 6y agoYeah, usually the supervisor just restarts what it should, and the error is logged. That is enough for me, to be honest.
- MaXtreeM 6y agoIn my job I am developing an app in C#. When I joined the team, there was already a Result library which is just a wrapper for return types with predefined set of result codes and a field for error message. It was first used for our bridge with JavaScript code, now it is used (and abused) everywhere in the app. I though it was a big PITA when I joined the team but I see the value now and I think the .NET ecosystem is lacking some standart way of doing this. Yes, there are exceptions, but we have a some states which are not "failured" per se and the Result library fits this well. The system is not perfect because the set of predefined result codes cannot be used everywhere and I am constantly thinking about how it could be improved. Does anyone have a clean way to do error handling in C#?
- jcranmer 6y agoThe way I frame error handling is there are three kinds of errors: The first kind of error is the "programming oops" error--division by 0, null pointer dereference are obvious examples. These kinds of errors potentially arise from almost everywhere, but you're never expecting to handle them except at the very highest catch-all level. Consequently, you generally want to have maximum-detail error logging of the entire stack trace and perhaps the environment around the cause of the failed exception to understand what wrong. The second case of error is the expected error class--I/O exceptions and file-not-found are the prototypical examples here. Generally, this will be handled immediately, and you may be unwilling to pay the high performance penalty of constructing the exception context if you are going to throw that context away on the very next line. Instead, your want your exception context to be no more expensive than an error code. The third and final class of error is application errors that combine both properties. You want contextual information for errors, explaining not just what went wrong but also the sequence of processing decisions that led you to do that wrong step (e.g., pointing to configuration file entries). A stack trace isn't necessarily the worst default for context, but it's usually far from the best information. The application, when handling the error, may well want to poke heavily at the error details to display it in more user-friendly ways. As it exists at present, most error handling systems focus on either the first or second class of errors. More rarely, they try to handle both of these classes (Rust's Result/panic divide is an example here). But there is very little support for the third class, with the closest alternative being providing context via chaining of exceptions.
- chousuke 6y agoI feel like sometimes software could benefit from a better conceptual differentiation between failures and errors. Failures happen, and robust software should deal with them in a predictable manner. Not all software needs to be able to recover from failures automatically, but they should remain in a consistent state so that an operator can fix the situation, allowing the system to recover. A database system unexpectedly going down is a failure, and a system that expects it will never happen can't be considered correct. Errors are the result of a mistake: Either there's an invalid input from the user, or a bug has caused data corruption. Errors can be detected with defensive coding (eg. by validating input), but often systems can't recover from errors, so they to be communicated to the user. You'll want systems to abort their current task if they encounter errors, as the state will be undefined, and continuing may make the situation worse.
- steveklabnik 6y agoIn Rust, we call these "recoverable" vs "unrecoverable" errors, and use Result and panic for them, respectively.
- svnpenn 6y agoHere is my solution: use std::fs; fn main() -> Result<(), String> { let s = "a.rs"; match fs::read_to_string(s) { Ok(v) => print!("{}", v), Err(v) => Err(format!("{} {}", s, v))? } Ok(()) } or you can use `map_err`: let out_s = fs::read_to_string(in_s).map_err(|e| format!("{} {}", in_s, e) )?;
- davidhyde 6y agoI write a quite a bit of [no_std] Rust code (i.e. with no standard library) and you don't get things like a memory allocator or even a panic handler out of the box. However, I find that creating a custom error enum for each of my modules and piping errors into errors by implementing the From trait for each Error enum referenced inside those modules is not as tiresome as one would expect. It frees me up to use the ? operator wherever I want and add whatever "context" I want by using structured enums. That way, when an Err is returned instead of an Ok result variant you get a pseudo-stacktrace via your error chain and the error variant itself can have data in it (like an index or a key that could not be found). It's up to you how specific or generic you want it to be. You can also use "map_err" to decorate fallible code you don't control with extra contextual information that will help you locate the source of the error. For example: `Error: CookingError(CakeBaker(TemperatureCOutOfRange(999, 250)))` This has the added benefit of not forcing your error handling methodology on users of your libraries.
- Cojen 6y agoReading over the comments here makes me chuckle a bit because it proves the author's point: "Error handling is hard". I don't think any silver bullet solution has been invented yet. I'd like to see error handling supported in a transactional way, which at least would roll things back to a clean state before the error happened. Of course if you performed an operation which cannot be rolled back, then so much for my great idea.
- higerordermap 6y agoI don't get all the exception hate. Exceptions are great when you are prototyping something. You will not have to bother about the very very very edge case. This is important when you have HOFs. For robust production software though, you need something like result type or checked exceptions.
- qppo 6y agoIt's probably because too many of us have written too many prototypes that turn into production software
- TheCoelacanth 6y agoMy problem with them is that they masquerade as a real error handling solution. Rust's panic is better since it makes it clear that you are just completely giving up and that the scenario is not being handled.
- mplanchard 6y agoSurprised no one has mentioned the color-eyre[0] crate yet, which gives very nicely formatted errors with stacktraces if desired. The author gave a great talk about error handling at this year’s rust conf[1]. [0]: https://docs.rs/color-eyre/0.5.4/color_eyre/ https://docs.rs/color-eyre/0.5.4/color_eyre/ [1]: https://m.youtube.com/watch?v=rAF8mLI0naQ https://m.youtube.com/watch?v=rAF8mLI0naQ
- enriquto 6y agoErrors are wrong. Or, to put it more clearly: there are no errors, only conditions that you dislike. It's better to not burden your programming with your emotional shortcomings, and treat all conditions that you may encounter on an equal footing. You try to open a file; the file may or may not exist, and both cases are equally likely and you get to decide what your program does in each case. No need to attach an emotionally charged label like "error" in one of the two cases of the conditional. Or worse, as some emotional fanatics do, to bend an otherwise clean programming language by adding features (e.g., exceptions) that help support your sentimental disposition.
- johnisgood 6y agoI am fine with Forth's exception handling, or even Lisp's and Factor's.