8 ms·
Error Handling Patterns
- waf 3y agoAn older yet still excellent take on this topic is https://joeduffyblog.com/2016/02/07/the-error-model/ https://joeduffyblog.com/2016/02/07/the-error-model/
- yingliu4203 3y agoAgree, many practical insights in the system programming domain -- the domain is important for any discussion. For example, the unchecked exception pattern is a good choice for business applications.
- theshrike79 3y agoMy preference is either a Result-object or the Go convention of (result, error). Both force you to handle the error or explicitly ignore it, which is the correct way. Either you take care of it there if you have the context to figure out if it's relevant or not or you explicitly bump it one step up for the caller to handle.
- usrnm 3y ago(result, error) is very bad. It forces you to return some result even if there was an error. I've seen real production problems caused by using this garbage value after an error has happened.
- jspdown 3y agoCould you give examples of problems you encountered with this approach? The only issue I can think of comes from ignoring the error. You get the same problem with Rust's Result btw. Static analysis helps in this regard, golangci-lint catches easily such mistakes. In my experience, with production tooling, I never encountered what you described. Though, from a pure aesthetic point of view I prefer Rust's Result.
- red_admiral 3y agoIf you return an `Either[Error, T]` (to use Scala syntax) then in case of error you never have to construct a T and the caller can never read one by accident on the error path, whereas if you return a pair (T, error) then you can hit at least three cases: 1. T is an int that's sometimes legitimately zero, if you return (0, err) and the caller isn't careful they can look at the 0 without realising it's not valid. 2. T is a 'pointer' type so you return (null, error) and the caller tries to dereference the pointer anyway (one example I've seen is inserting a logging statement for debugging purposes just _before_ the error-check, along the style of `logger.debug("foo {t.name}")` where the braces interpolate stuff). 3. T lives in a strongly typed world where there's no convenient null value available (maybe your language distiniguishes between "definitely a T" and "either a T or null") so you somehow have to construct an instance of a T even though you're never going to use it and it'll most likely be invalid in some sense. That doesn't mean the golang approach is bad - especially not in golang itself - but you do need to know how to use it correctly. Then again, using monadic error handling in golang would probably be strictly inferior - it's not that you can't define one, but the language doesn't have the syntactic sugar to handle it like e.g. Scala does, and I find that being able to use that sugar to get some readability for the approach is the whole point of monadic error handling.
- usrnm 3y agoCallee: creates a complex object and does several steps of initialisation. Last step fails, so it returns a partially initialized object and an error. Caller: checks the error, which is not fatal, but expects to get a nil in this case. Ends up with a non-nil partially initialized garbage. Yes, someone screwed up. Yes, it's a bug. Doesn't change the fact that there are better error handling approaches that eliminate this kind of bugs completely
- nordsieck 3y agoYou're right that the language doesn't protect you here. One easy way to prevent these sorts of errors is to always return default literals with errors (in the same spirit of "value == var" from c to prevent accidental assignment).
- 3y ago
- skitter 3y agoSadly Go doesn't force you to handle the error (this passes go vet): a, err := strconv.Atoi("blah") b, err := strconv.Atoi("123") if err != nil { log.Fatal(err) } return a + b This could be fixed to emit a vet error, but ultimately it is inferior to only returning either error or success, instead of returning both even through logically only one exists.
- majewsky 3y agoNot to take away from your argument, but for what it's worth, there is a lint for this [1] which is available in golangci-lint [2]. If there is any Go developer reading this who does not know about golangci-lint, I wholeheartedly recommend looking into it. It's found a fair share of significant issues for me that otherwise would have required tests to uncover. [1] https://github.com/gordonklaus/ineffassign https://github.com/gordonklaus/ineffassign [2] https://golangci-lint.run/ https://golangci-lint.run/
- masklinn 3y ago> Both force you to handle the error That is definitely not true of the second, especially when the language is incorrectly or insufficiently pedantic like Go. Notably, because the base pattern is foo, err := Bar() it is very easy to write something like: foo, err := Bar() // code, no error check bar, err := Baz() // code, no error check qux, err := Qux() if err != nil { ... } And Go is perfectly happy with that, because while it will complain about unused variables it doesn't say anything about dead stores. You need external tools to warn about this issue, as well as ignoring the returns entirely. And then of course because this pattern requires having a valid "result" value which ends up in scope, it's very easy for bugs to creep in where this result value does get used even in case of error.
- nordsieck 3y agoIMO, that would look suspect to me. It's much more common to see if foo, err := Bar(); err != nil { It's a pretty immediate yellow flag for me if you're not doing that. In general, I've found that not catching errors is not a practical problem when writing go. Whereas things like interface nil ambiguity and the way defer and closures interact bit me a few times.
- mdaniel 3y agoThat's all fun and games if you want `foo` to be used in some `else` block but it just straightup wrong for using `foo` further down in the method as it scopes the decl (`:=`) to the `if` package main import ( "fmt" ) func Bar() (string, error) { return "hello", nil } func main() { if foo, err := Bar(); err != nil { panic(err) } fmt.Printf("foo is %q", foo) } $ go run foo.go ./foo.go:12:8: foo declared and not used ./foo.go:15:30: undefined: foo > In general, I've found that not catching errors is not a practical problem when writing go Yeah, good for you, but I am obviously not a consumer of your code because I have lost track of the number of times I have to use software written by folks that try to be heroes and program using dumb editors that cheerfully don't warn about swallowing err
- divan 3y agoI'm writing Go and Dart daily for many years (Go - 9 years, Dart - 4 years). Looking at the Go code (especially old one) I can immediately understand how and where error path is handled. In Dart code it's almost never the case – you just hope that it's handled somewhere in a right place. If I want to really find this place – it's a quest with 10-15 files open. Needless to say I end up inspecting unexpected stacktraces often (rife use of generics also lead to ubiqutuous errors like "type 'Null' is not a subtype of type 'bool'" – that's with Null safety enabled). I think Dart will be introducing even more features and pattern matching and everything-else-that-exists-in-other-languages. Complexity is piling up more and more around error handling in many languages. Go seems to be the only one holding up the ground of caring about cognitive load on developers and code readability.
- samber 3y agoFor those who would rather use an Either type instead of returning 2 variables in Go, I made this: https://github.com/samber/mo https://github.com/samber/mo
- KingOfCoders 3y agoGreat that we have more discussions about error handling. Shameless Plug: "Musings about error handling mechanisms in programming languages" https://www.amazingcto.com/best-way-to-handle-errors-for-a-programming-language/ https://www.amazingcto.com/best-way-to-handle-errors-for-a-p...
- cjr 3y agoVery timely, was just trying to understand how to improve error handling with typescript recently and came across neverthrow (https://github.com/supermacro/neverthrow https://github.com/supermacro/neverthrow) which looks promising…
- difosfor 3y agoYeah, working with exceptions and Promise rejections is very annoying in Typescript as the error type is often any even when you yourself carefully always use Error instances to be able to get a stack trace etc. Theoreticallly at least some sub/system function might throw a string or whatever, so that has to be dealt with. Also subtyping Error classes to be able to convey error details is annoying and you'll then have to document all expected error types. And finally Typescript won't be able to help you ensure that you're dealing with all of them. This seems to solve all of that, but I haven't yet had a chance to try it out.
- creakingstairs 3y agolooks like more ergonomic/focused version of fp-ts[1] Thanks for sharing! [1] https://gcanti.github.io/fp-ts/ https://gcanti.github.io/fp-ts/
- linkdd 3y agoIt's missing the Erlang/Elixir pattern of returning a tuple `{:ok, T}` or `{:error, E}`, where we can then use pattern matching, or `with` expressions, etc... To be fair, it is very similar to a `Result<T, E>` type, which is why I made this library a while ago: https://github.com/linkdd/rustic_result https://github.com/linkdd/rustic_result
- masklinn 3y ago> To be fair, it is very similar to a `Result<T, E>` type It's basically the same thing, in a language with pattern matching but not static typing. In Erlang/Elixir it's conventional to use tagged tuples to emulate sum types.
- geoelkh 3y agoOne of my favorite feature of GoLang
- gpderetta 3y agoObviously exceptions are vastly superior to sum types for errors. Compare the following very readable exception based-pseudocode: fn doSomethingFallibly() -> SameVal, except Error SomeVal x = ...; if failed: raise Error else return x fn doTheThing() -> void try Someval x = doSomethingFallibly() /* use x */ except Error: /* log the error */ To the utterly unreadable error variant based implementation that uses obscure functional stuff like patter matching: fn doSomethingFallibly() -> SameVal | Error SomeVal x = ...; if failed: return Error else return x fn doTheThing() -> void match doSomethingFallibly(): SomeVal x: /* use x */ Error: /* log the error */ This is especially egregious if you want to propagate the error. The exception based variant can focus on the success control flow: fn doTheThing() -> void, raises Error SomeVal x = try doSomethingFallibly() While for the error variant case you have to use even more obscure monadic code: fn doTheThing() -> void|Error SomeVal x <- doSomethingFallibly()?
- red_admiral 3y agoHere's sum types in scala 3: for x <- fn1() y <- fn2(x) z <- fn3(y) yield z That is both readable and safe, here fn1-fn3 all return an Either[Error,T] (the error type has to be common but the success type can vary by function).
- js8 3y agoThe advantage of errors as return values is simplicity and localization of the impact. Each of the three major paradigms (return values, exceptions, error handlers) has pros and cons, neither is strictly superior.
- tialaramex 3y agoWas this missing a /s ? Poe's law applies so hard to people's language syntax preferences. By which I mean most of you are wrong, to be clear.
- gpderetta 3y agoCheck my other top level comment in this thread :).
- gpderetta 3y agoObviously sum types for errors are vastly superior to exceptions. Compare the following very readable Error variant pseudocode: fn doSomethingFallibly() -> SameVal | Error SomeVal x = ...; if failed: return Error else return x fn doTheThing() -> void match doSomethingFallibly(): SomeVal x: /* use x */ Error: /* log the error */ To the utterly unreadable exception-based implementation: fn doSomethingFallibly() -> SameVal, except Error SomeVal x = ...; if failed: raise Error else return x fn doTheThing() -> void try Someval x = doSomethingFallibly() /* use x */ except Error: /* log the error */ This is especially egregious if you want to propagate the error. For the error variant case you can use a beautiful monadic solution fn doTheThing() -> void|Error SomeVal x <- doSomethingFallibly()? While the exception based variant completely obscures the control flow: fn doTheThing() -> void, raises Error SomeVal x = try doSomethingFallibly()
- golergka 3y agoDepends on where do you want to handle a particular error. For errors that you can handle right in the calling function, this is ideal. For errors that should kill your whole aapp, may be do some housekeeping (like abort the whole transaction that wraps your logic to reply to this http request) and get reported to sentry, catching exceptions at some highest level is far more usable. I prefer to use both depending on meaning of the error.
- AnimalMuppet 3y agoI don't like your approach. In general, I'm a fan of "use the right tool for the job". But for error handling, I prefer one approach for the whole application. If you mix approaches (exceptions here and sum types there), you tend to get bugs at the seams between the regions. If you mix them more uniformly in the code, that tends to be uniformly harder to reason about.
- golergka 3y agoInteresting point. What would you consider to be the seam between regions in this case?
- hoseja 3y ago>Unless you are writing “hello world” Ackshually, ...
- hsn915 3y agoThere's another approach that I don't see discussed often: structure operations so that code just "passes through" on errors and code the happy path. In the case of opening a file, you would get an invalid file handle that you can still pass around to functions and nothing would "fail"; it would just be as if you passed a handle to /dev/null. This scheme requires that you can still explicitly check if the handle is valid. Most people are familir with some form of this with the special NaN floating point value.
- iainmerrick 3y agoI find NaN mostly awkward and annoying in practice. Writing graphics code, you often find that some rendering step just fails completely, and it's because you had a NaN (almost always divide by zero) somewhere along the line. Trying to pin down exactly where it occurs can be a real pain. Rather than silently passing NaN along, it would be much more useful to fail immediately and loudly. Therefore, I'd be very wary of using this pattern elsewhere, unless you can carry along a backtrace to help track down the culprit. On the other hand, it is useful that GL shaders will still sorta-kinda work even if you have zero divides, rather than completely blowing up. In many cases this only happens on a single pixel with very specific inputs, and you can just ignore it -- like having a single dead pixel on a monitor.
- kevin_thibedeau 3y agoNaN does it's one job when it is annoying. It exposes errors at their origin before they have an opportunity to propagate. If you're generating NaNs your code is broken.
- iainmerrick 3y agoAt their origin? How so, when you can just keep calculating and you don't get exceptions, just more and more NaNs? Edit to add: like, if the error is "distance was zero so I couldn't invert the matrix", I want that to be the error feedback; not "the screen is black because the final result of all the camera calculations was NaN".
- 3y ago
- red_admiral 3y agoThe monadic/Result pattern only works if your language has a lot of syntactic sugar for it (for example to mix functions that can return errors with ones that can't you need some kind of 'bind'), but when you're used to it and don't try and play too many clever tricks on the side (not _everything_ has to be a monad) then it can be very readable and easy to work with.
- js8 3y agoError handlers, what is called callbacks in the article, have existed since time immaterial in operating systems. They have also been popular in PL/I and Lisp as signals and conditions. Unfortunately, Unix implementation kinda limited them (there is limited number of system signals, error handlers do not stack), so they never became really popular in C.
- mirekrusin 3y agoMissing: * errors from async-colored functions (ie. .then/.catch/.finally and higher order combinators on them, ie. we use a lot of p.catch(log.rescue(...))) * errors in generators * errors in async generators Using functional approaches helps working with things like managing severity, error codes, nested errors etc. that may be attached to errors as well as managing higher level concepts like timeouts, retries etc. This article reads like hello world for errors, there is more to it.
- masklinn 3y agoNot a great article, very little depth, only a shallow mostly syntax-level overview. Notably doesn't at all explore the consequences of those choices in how they relate to convenience, (type) safety, composability, interaction with generics, ... beyond merely mentioning the failure of java's checked exceptions. It's also severely missing in breadth e.g. Swift and Zig use an exception-type syntax, but result-type semantics, it does not cover conditions and restarts, or takes like Common Lisp's MRV, where the ancillary return values are treated as secondary and interacted with through special functions.
- Joker_vD 3y ago> For example, "printf" in C can fail, but I have not seen many programs checking its return code! Okay, I add if (printf(...) < 0) { // TODO: Handle error } around the printf call. Now what? How do I handle the error? In most realistic scenarios I can imagine I'd either just ignore it and keep going, or print an error and abort (but what if printing an error fails too? Oh no...), or I'll never actually even get the chance to handle the error because my program will get killed by a SIGPIPE. Seriously, if the printf fails then (barring the malformed format string) that means that the underlying device lost its ability to output data and this ability is most likely not coming back, and your program generally can't do anything with it, or even know about something to do: the functions from the FILE*-family abstract the underlying devices extremely well.
- Someone 3y ago> Seriously, if the printf fails then (barring the malformed format string) that means that the underlying device lost its ability to output data I can think of several other reasons: - temporary glitch - multi-byte character conversion fails because the bytes sent and the current locale don’t match - printf runs out of memory There probably are more.
- Joker_vD 3y ago> temporary glitch Ideally, that properly should be handled in the device driver, with buffering and retries, and not bubble all the way up. But even if it does, you can't reasonably retry the printf call since there is no guarantee that zero data has actually been written. > multi-byte character conversion fails because the bytes sent and the current locale don’t match That falls under "malformed format string" category; using printf for MBCS-conversion is almost always a programmer's error. > printf runs out of memory That falls under both "stupid library implementation" (printf doesn't need to malloc) and "still can't recover from that".
- Someone 3y ago> you can't reasonably retry the printf That’s changing the subject. The comment I replied to claimed “the underlying device lost its ability to output data”, not that retrying wasn’t an option. My point is that the call can fail even though the device is fine. > printf doesn't need to malloc The standard allows it to do so, and https://stackoverflow.com/a/6743056 https://stackoverflow.com/a/6743056 claims glibc did that in 2011. Looking at https://github.com/lattera/glibc/blob/895ef79e04a953cac1493863bcae29ad85657ee1/stdio-common/vfprintf.c#L1025 https://github.com/lattera/glibc/blob/895ef79e04a953cac14938... and https://github.com/lattera/glibc/blob/895ef79e04a953cac1493863bcae29ad85657ee1/stdio-common/vfprintf.c#L1638 https://github.com/lattera/glibc/blob/895ef79e04a953cac14938..., it seems vfprintf still can do that today, but I didn’t check whether printf can call that. > and "still can't recover from that". See above; I never claimed that. Also, this action (in general) isn’t recoverable, but programs sometimes can recover from out of memory conditions, for example by clearing their caches or by freeing a block of memory specially allocated at startup to ensure some recovery from an out of memory condition is possible.
- red_admiral 3y agoAnother take on this is the "Kroll result" after Rachel Kroll of rachelbythebay fame. Basically, it's a C++ type "Result<T>" with methods like "bool isError()" and "T get()" or something like that, but the twist is that it contains a private boolean field "checked" initialized to false, but set as a side-effect of calling isError. If you call get() and checked == false, then it blows up with an exception (I presume it does that too if you call get() and it's actually an error). That way, if you ever write code that doesn't error-check before trying to get the "happy path" result, you'll notice immediately, even if it's the kind of thing that very rarely fails and you haven't written a unit test for it. A chaotic good CS educator might want to try out an experiment: give the students an assignment where they have to work with an API that uses a normal result type, explain why error-checking is really important, but when you're marking the assignment switch out the result type for the Kroll one.
- prng2021 3y agoInteresting! I never heard of this pattern but will consider using it. Any idea why it’s not more popular?
- tialaramex 3y agoI guess you mean this ? https://rachelbythebay.com/w/2022/02/21/result/ https://rachelbythebay.com/w/2022/02/21/result/ The type isn't described there as "Kroll result" perhaps out of modesty, but I also found no other reference to this type by that name, and it seems more like an unfinished thought than an actual type you'd use. Rachel seems fixated on people learning a lesson from using it wrong, rather than shifting hard left like Rust's Result so that when you get this wrong your code doesn't compile the same as if you forget to quote strings, or you write the actual arithmetic product symbol instead of an asterisk. There is no need for this code to compile in order to "learn" something. That's the same lesson which scraped into C++ 20 errata, to make std::format("{} {}", 5); into a compile error whereas previously it was ludicrously a runtime error. Maybe at runtime the literal 5 will be two values ? No. We don't need to execute this code to realise that it's wrong. Shift hard left.
- red_admiral 3y ago
- notacoward 3y agoNice article, and I'm not just saying that because I wrote something extremely similar myself a few years ago. ;) The one thing I'd add is that the "defer" construct deserves a mention. It's not a comprehensive error handling mechanism by itself, but can often augment or even stand in for one.
- readthenotes1 3y agoIt's missing two common error handling techniques: -ignore them -squelch them ("this could never happen")