Anemic stack traces in Go

Redowan Delowar February 10, 2024
Source
While I like Go's approach of treating errors as values as much as the next person, it inevitably leads to a situation where there isn't a one-size-fits-all strategy for error handling like in Python or JavaScript. The usual way of dealing with errors entails returning error values from the bottom of the call chain and then handling them at the top. But it's not universal since there are cases where you might want to handle errors as early as possible and fail catastrophically. Yet, it's common enough that we can use it as the base of our conversation. This simple but verbose error handling works okay and makes us painfully aware of all the possible error paths. Yet, the model doesn't hold up as your program grows in scope and complexity, forcing you to devise custom patterns to add context and build thin stack traces. There's no avoiding that. But the good thing is that building an emaciated stack trace is fairly straightforward, and some of the patterns are quite portable. After reading [Rob Pike's Upspin error handling blog], I had some ideas on creating custom errors to emulate stack traces. I ended up spending a few hours this morning experimenting with some of the ideas in a more limited scope. Let's say we're building a file-copy service that will accept a src and dst path and copy the contents from source to destination. This typical error handling pattern involves returning error values from lower-level functions and addressing them in top-level ones. Here, the main function manages the error: Running this function gives us the following output: This is usually enough if you're building a CLI or a small program. Also, squinting at the error message gives us a hint that among the 4 error-return paths, the copyFile function bailed at the first one when it couldn't find the source file. A proper way to handle this in larger applications is to wrap the errors and provide them with your own context. Then, in the top-level function, you can unwrap the error message or just log it verbatim as before. So, copyFile can be rewritten as follows: Notice how we're adding extra context to the error values with the %w verb in the fmt.Errorf function. If you keep the previous main function unchanged and run it, you'll get the following output: This time, since you know where you added the context, you also know which error-path the copyFile function returned from. However, even in this case, the main function just relays whatever comes out of copyFile and logs the error message. How would you make the error message prettier without losing context? Also, how would you attach file names and line numbers to make debugging easier? The debugging part isn't an issue in languages that support stack traces, this is usually taken care of automatically. Now, whether that's a good thing or a bad thing is a discussion for another day. We can define a custom error struct to represent a generic error in the package that houses copyFile. Inside the Error struct, Op represents the name of the function that the error originates from, Path is the file path, LineNo and FileName denote the precise location of the error, Err is the original error we're wrapping, and finally the debug boolean is be used to control the verbosity of error messages. Then the Error() method on the struct builds either a rudimentary stack trace or a prettier error message depending on the value of the Debug flag. The Error struct can be constructed with the following constructor function: This uses the runtime package to add the location data of the caller. It'll be called in the copyFile function as follows: You can turn on the Debug flag to print the stack trace in the main function: The output will be: Toggling Debug to false and running the snippet will return: You can add even more context to this error in different calling locations like this: It'll be pretty-printed like this when Debug is false: Now depending on your needs, you can customize the Error struct and NewError constructor to enable more elaborate error tracing. However, this isn't a proper stack in the sense that it only unwinds errors one level deep. But it can be extended to recursively build the full error trace if needed. The [Upspin error package] demonstrates a few techniques on how to do so. But for this particular case, anything more than a level deep stack is borderline overkill. Here's the [complete example on GitHub]. Fin! [rob pike's upspin error handling blog]: https://commandcenter.blogspot.com/2017/12/error-handling-in-upspin.html [upspin error package]: https://github.com/upspin/upspin/blob/master/errors/errors.go [complete example on github]: https://gist.github.com/rednafi/d090a16ba6ddd19c7fe8bdaae746205c

Discussion in the ATmosphere

Loading comments...