Preamble#
In this series I’ll walk through how I came to write an error library at Zircuit circa 2022. I’ll do this by rebuilding it to be more useful to the wider world than the existing public version.
The Problem#
When Go introduced generics in Go 1.18, many developers were looking forward to implementing various data structures like stacks, queues, and graphs. I’ll admit, that is what I expected I would do first as well. However, my very first real-world need came from error handling and logging errors with the appropriate context.
Consider the following Go code:
package main
import (
"fmt"
"log/slog"
"net/http"
"os"
)
var ErrHTTPCall = fmt.Errorf("non-2XX response")
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := foo(); err != nil {
logger.Error("foo failed", slog.Any("error", err))
}
}
func foo() error {
resp, err := http.Get("https://api.github.com/does_not_exist")
if err != nil {
return err
} else if resp.StatusCode >= 300 {
return fmt.Errorf("status_code=%d, %w", resp.StatusCode, ErrHTTPCall)
}
return nil
}Which produces a log output similar to this:
{
"time": "2009-11-10T23:00:00Z",
"level": "ERROR",
"msg": "foo failed",
"error": "status_code=404, content_len=106, non-2XX response"
}This log message contains all the information that the author wanted to convey, but it isn’t ideal. A log parser has only msg and error fields to work with here, so the extra information is difficult to parse out and/or index on.
Using a Custom Error Type#
This example is contrived, but not that far from reality. To solve this problem, one might make a custom error type. Let’s see what that looks like:
type FooError struct {
Err error
StatusCode int
ContentLen int64
}
// it must implement the error interface
func (f FooError) Error() string {
return f.Err.Error()
}
func (f FooError) Unwrap() error {
return f.Err
}Using it in our example:
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := foo(); err != nil {
var errFoo FooError
// NOTE: always check the return value here
_ = errors.As(err, &errFoo)
logger.Error(
"foo failed",
slog.Any("error", errFoo),
slog.Int("status_code", errFoo.StatusCode),
slog.Int64("content_len", errFoo.ContentLen),
)
}
}
func foo() error {
resp, err := http.Get("https://api.github.com/does_not_exist")
if err != nil {
return err
} else if resp.StatusCode >= 300 {
// return our custom error type
return FooError{
Err: ErrHTTPCall,
StatusCode: resp.StatusCode,
ContentLen: resp.ContentLength,
}
}
return nil
}Which produces a log output similar to this:
{
"time": "2009-11-10T23:00:00Z",
"level": "ERROR",
"msg": "foo failed",
"error": "non-2XX response",
"status_code": 404,
"content_len": 106
}As you can see, the custom error got us exactly what we wanted - except now our caller needs to know about FooError and how to handle it specifically. That won’t scale.
Adding the LogValuer interface#
Since we are using log/slog (Go 1.21) here in this example already, let’s also implement the LogValuer interface
.
func (f FooError) LogValue() slog.Value {
return slog.GroupValue(
slog.Any("error", f.Err),
slog.Int("status_code", f.StatusCode),
slog.Int64("content_len", f.ContentLen),
)
}and now in main we can go back to just:
if err := foo(); err != nil {
logger.Error("foo failed", slog.Any("error", err))
}Which produces a log output similar to this:
{
"time": "2009-11-10T23:00:00Z",
"level": "ERROR",
"msg": "foo failed",
"error": {
"error": "non-2XX response",
"status_code": 404,
"content_len": 106
}
}Excellent.
We now have a custom error where the caller doesn’t need to know anything about it at all. There is a potential problem in that the top-level error field is now a struct instead of a string. Some log ingestion systems may not handle this well. Unfortunately because LogValue() returns a single slog.Value there isn’t much we can do about that right now. I’ll come back to this in a separate post.
Now all we need to do is the exact same thing for every other kind of error that could use some additional data…
Error Wrapping vs Terminal Errors#
In our custom error, we are actually just wrapping another error and adding additional information in a kind of sidecar. We could have implemented FooError.Error() to return a static string and not actually embed another error, but then we would not be able to do things like this:
var ErrBase = fmt.Errorf("base error")
func main() {
one := FooError{Err: ErrBase, StatusCode: 201}
two := FooError{Err: ErrBase, StatusCode: 202}
oneAgain := FooError{Err: ErrBase, StatusCode: 201}
fmt.Println(errors.Is(one, ErrBase)) // true
fmt.Println(errors.Is(two, ErrBase)) // true
fmt.Println(errors.Is(one, two)) // false
fmt.Println(errors.Is(one, oneAgain)) // true
}If you would prefer to have a terminal error (one that does not wrap another), then just be aware of the implications of those last two lines: different instances of the same error type are only equal if their contents are equal.
As an aside, you can actually override this by implementing Is(target error) bool for your custom error (if it wraps another or not). For example:
func (f FooError) Is(other error) bool {
target, ok := other.(FooError)
if !ok {
return false
}
// consider the same 100 series of status codes to be equal
return f.StatusCode/100 == target.StatusCode/100
}
var ErrBase = fmt.Errorf("base error")
func main() {
one := FooError{Err: ErrBase, StatusCode: 201}
two := FooError{Err: ErrBase, StatusCode: 202}
three := FooError{Err: ErrBase, StatusCode: 300}
fmt.Println(errors.Is(one, two)) // true
fmt.Println(errors.Is(one, three)) // false
}A Generic Error Wrapper#
Instead of all the copy-paste, let’s use generics to solve our general problem of adding sidecar data to an error:
type ExtendedError[T any] struct {
err error
Data T
}
func (e ExtendedError[T]) Error() string {
return e.err.Error()
}
func (e ExtendedError[T]) Unwrap() error {
return e.err
}
// implement slog.LogValuer interface (go 1.21+)
func (e ExtendedError[T]) LogValue() slog.Value {
return slog.GroupValue(
slog.Any("error", e.err),
slog.Any("data", e.Data),
)
}
// wrap an error with additional data
func Extend[T any](data T, err error) error {
if err == nil {
return nil
}
return ExtendedError[T]{Data: data, err: err}
}
// returns wrapped data if possible, even in cases of deeply nested wrapping.
// NOTE: If an error is extended multiple times with the same data type,
// then only the nearest matching type is returned.
func Extract[T any](err error) (T, bool) {
e, ok := errors.AsType[ExtendedError[T]](err) // errors.AsType is new in Go 1.26
return e.Data, ok
}And to use it in our example, we could write:
type FooData struct {
StatusCode int
ContentLen int64
}
func foo() error {
resp, err := http.Get("https://api.github.com/does_not_exist")
if err != nil {
return err
} else if resp.StatusCode >= 300 {
return Extend(FooData{
StatusCode: resp.StatusCode,
ContentLen: resp.ContentLength,
}, ErrHTTPCall)
}
return nil
}Which produces a log output similar to this:
{
"time": "2009-11-10T23:00:00Z",
"level": "ERROR",
"msg": "foo failed",
"error": {
"error": "non-2XX response",
"data": {
"StatusCode": 404,
"ContentLen": 106
}
}
}The output isn’t identical: data is now a field and the contents are nested within. This may or may not suit our logging needs as-is, but the important thing is that all the data is present and accessible. I’ll write a separate post in the future about how we can further tailor the logging output.
Another thing to note is that in this case, the only thing we needed to do was create and use the struct FooData; we did not need to implement FooData.LogValue. If we want to change how it appears in a log, then we would only need to implement it and it would be picked up automatically. However, if we only cared about the field names being lower snake case for our JSON logs, we just need to add json tags to the struct like so:
type FooData struct {
StatusCode int `json:"status_code"`
ContentLen int64 `json:"content_len"`
}Next Steps#
The complete code for our generic error wrapper can be found at github.com/wood-jp/xerrors . Note that the code will likely differ from what was presented here as it is expanded and improved over time.
In my next article in this series, I’ll provide a concrete use case for our generic error wrapper, which I hope will make the value of this wrapper much more clear.
Related Reading#
No one writes code in a vacuum. Here are some helpful resources that helped me with writing this article:
Some code in this article is based on work from zkr-go-common , licensed under the MIT License, originally authored by wood-jp at Zircuit
