Anemic stack traces in Go
Redowan Delowar
February 10, 2024
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