Live data from Hacker News

Structured Errors in Go (2022)

southcla.ws

1–10 of 75 posts

Re: Structured Errors in Go (2022)

#3
One thing that seemingly is missing is the ability to tag a specific error with an error code. You typically want to know that all of a sudden the ”failed to get user” error is being returned a lot. Since the message is a dynamic string you can’t just group by the string so unless you build it as part of your abstraction it becomes very hard to do.

Edit: looking more carefully at the lib I assume that ”tag” is the concept that is supposed to cover this?

Re: Structured Errors in Go (2022)

#5
These are good general tips applicable to other languages too. I strongly dislike when code returns errors as arbitrary strings rather than classes, as it makes errors extremely difficult to handle; one would presumably want to handle a http 502 diffrernetly to a 404, but if a programmer returns that in a string, I have to do some wonky regex instead of checking the type of error class (or pulling a property from an error class). I've commonly found JS and Go code particularly annoying as they tend to use strings, as the author mentioned.

An additional thing that is useful here would be a stack trace. So even when you catch, wrap & rethrow the error, you'll be able to see exactly where the error came from. The alternative is searching in the code for the string.

For the hate they seem to get, checked exceptions with error classes do give you a lot of stuff for free.

Re: Structured Errors in Go (2022)

#6
The `WithMeta` func will panic if not passed a multiple of 2 varargs. This is exactly what makes go error handling difficult in the first place. Imagine panicking in production because you passed a key without value to your error wrapper for some reason.

Re: Structured Errors in Go (2022)

#7
The implementation of WithMeta() is flawed. Not only is it not concurrency-safe, every nested call will be modifying the parent map.

The way to do this in a safe and performant manner is to structure the metadata as a tree, with a parent pointing to the previous metadata. You'd probably want to do some pooling and other optimizations to avoid allocating a map every time. Then all the maps can be immutable and therefore not require any locks. To construct the final map at error time, you simply traverse the map depth-first, building a merged map.

I'm not sure I agree with the approach, however. This system will incur a performance and memory penalty every time you descend into a new metadata context, even when no errors are occurring. Building up this contextual data (which presumably already exists on the call stack in the form of local variables) will be constantly going on and causing trouble in hot paths.

A better approach is to return a structured error describing the failed action that includes data known to the returner, which should have enough data to be meaningful. Then, every time you pass an error up the stack, you augment it with additional data so that everything can be gleaned from it. Rather than:

    val, err := GetStuff()
    if err != nil {
      return err
    }
You do:

    val, err := GetStuff()
    if err != nil {
      return fmt.Errorf("getting stuff: %w")
    }
Or maybe:

    val, err := GetStuff()
    if err != nil {
      return wrapWithMetadata(err, meta.KV("database", db.Name))
    }
Here, wrapWithMetadata() can construct an efficient error value that implements Unwrap().

This pays the performance cost only at error time, and the contextual information travels up the stack with a tree of error causes that can be gotten with `errors.Unwrap()`. The point is that Go errors already are a tree of causes.

Sometimes tracking contextual information in a context is useful, of course. But I think the benefit of my approach is that a function returning an error only needs to provide what it knows about the failing error. Any "ambient" contextual information can be added by the caller at no extra cost when following the happy path.

Re: Structured Errors in Go (2022)

#9

The implementation of WithMeta() is flawed. Not only is it not concurrency-safe, every nested call will be modifying the parent map. The way to do this in a safe and performant manner is to structure the metadata as a tree, with a parent pointing to the previous metadata. You'd probably want to do some pooling and other optimizations to avoid allocating a map every time. Then all the maps can be immutable and therefo…

I think this is the way to bubble up error messages that I like the most. Simple, not needing any additional tools, and very practical (sometimes even better than a stack trace).

The idea is to only add information that the caller isn't already aware of. Error messages shouldn't include the function name or any of its arguments, because the caller will include those in its own wrapping of that error.

This is done with fmt.Errorf():

    userId := "A0101"
    err := database.Store(userId);
    if err != nil {
        return fmt.Errorf("database.Store({userId: %q}): %w", userId, err)
    }
If this is done consistently across all layers, and finally logged in the outermost layer, the end result will be nice error messages with all the context needed to understand the exact call chain that failed:

    fmt.Printf("ERROR %v\n", err)
Output:

    ERROR app.run(): room.start({name: "participant5"}): UseStorage({type: "sqlite"}): Store({userId: "A0101"}): the transaction was interrupted
This message shows at a quick glance which participant, which database selection, and which integer value where used when the call failed. Much more useful than Stack Traces, which don't show argument values.

Of course, longer error messages could be written, but it seems optimal to just convey a minimal expression of what function call and argument was being called when the error happened.

Adding to this, the Go code linter forbids writing error messages that start with Upper Case, precisely because it assumes that all this will be done and error messages are just parts of a longer sentence:

https://staticcheck.dev/docs/checks/#ST1005

Re: Structured Errors in Go (2022)

#10

The implementation of WithMeta() is flawed. Not only is it not concurrency-safe, every nested call will be modifying the parent map. The way to do this in a safe and performant manner is to structure the metadata as a tree, with a parent pointing to the previous metadata. You'd probably want to do some pooling and other optimizations to avoid allocating a map every time. Then all the maps can be immutable and therefo…

Just a little more work, and you will reinvent exceptions with stack traces! Proper error handling in Go is now tantalisingly close!
Post reply on HN