Recap#
In the previous article in this series, we created a generic error wrapper that allowed us to attach type-safe data to another error.
The important parts we will need to reference are:
package xerrors
type ExtendedError[T any] struct {
err error
Data T
}
func Extend[T any](data T, err error) error { ... }
func Extract[T any](err error) (T, bool) { ... }In this article, we will explore and implement one concrete use case that makes use of this wrapper.
Use Case: Error Classification#
One of the simplest use cases I had was to allow for marking errors as belonging to a particular classification of errors.
Problem Description#
Consider that you make an SQL query to an external database, or perhaps a POST call to a REST API. What happens when you get an error back? Should you retry the call? Is the problem a temporary network issue, or was there something wrong with your inputs?
There’s no magic way to determine this - you need to know the details of your specific situation. On the other hand, if you do need to retry in some cases (and not others), you might find yourself writing a retry func that is now tightly coupled to this particular call. How can we decouple?
Ideal Solution#
As a developer, it would be nice to be able to write something like:
switch errType(err) {
case type1, type2, type3:
return errclass.WrapAs(err, errclass.Transient)
case type4, type5:
return errclass.WrapAs(err, errclass.Persistent)
default:
return errclass.WrapAs(err, errclass.Unknown)
}Then have a retry library that doesn’t need to know anything about the error types from our caller:
func Try(f func() error) error {
retryLoop:
for /* some conditions */{
err := f()
switch errclass.GetClass(err) {
case errclass.Nil:
cause = Success
break retryLoop
case errclass.Persistent:
cause = PersistentErrorEncountered
break retryLoop
}
// delay until next retry
}
// return appropriately based on cause
}Introducing The errclass Package#
Let’s make this a reality. First we need to decide what classes of error we want to support. We could skip type-safety and make these definable later, but that’s likely to lead to more trouble down the line. From our example above, we already have Nil, Transient, Persistent and the catch-all Unknown. I’m going to add Panic to that list, but we won’t make use of that just now (that’s for a future article).
You might ask: “Why Nil?”
The answer is right there in the example Try function: I’d like to have a switch errclass.GetClass(err) and unless I make the class types pointers, then we will need a class to represent a lack of error.
type Class int
const (
Nil Class = iota - 1 // start at -1
Unknown
Transient
Persistent
Panic
)It is generally best practice to make catch-all values such as our Unknown be associated with a zero-value. Since I want to have the actual values be comparable from least to most concerning, I’ve set Nil lower than Unknown and given it a -1.
Add some helpers for human readability:
func (c Class) String() string {
switch c {
case Nil:
return "nil"
case Panic:
return "panic"
case Transient:
return "transient"
case Persistent:
return "persistent"
default:
return "unknown"
}
}
func (c Class) LogValue() slog.Value {
return slog.GroupValue(
slog.String("class", c.String()),
)
}And now we actually leverage our previous work to implement WrapAs and GetClass:
func WrapAs(err error, class Class) error {
if err == nil {
return nil
}
return xerrors.Extend(class, err)
}
func GetClass(err error) Class {
if err == nil {
return Nil
}
if class, ok := xerrors.Extract[Class](err); ok {
return class
}
return Unknown
}Gotcha: Error Wrapping Chain#
One of the interesting side-effects of the error wrapper is that errors can be wrapped multiple times, even with the same type of wrapper. Consider this:
func foo() error {
if err := bar(); err != nil {
return errclass.WrapAs(err, errclass.Transient)
}
return nil
}
func bar() error {
return errclass.WrapAs(errSentinel, errclass.Persistent)
}What class of error is the one returned by foo()? What class should it be?
The answer to the first question is easy: Transient. This is because that’s the outer-most instance of ExtendedError[errclass.Class]. The other instance is still present and accessible via errclass.GetClass(errors.Unwrap(foo()), but one would have to know it exists in order to find it.
The second question is more difficult as the answer depends on one’s philosophy. Who should make the determination? Should an error class even be allowed to be overridden? Should overrides only be allowed when the severity is increased?
I would argue that the writer of any given code should always be the authority on what they want that code to do. As a library, the errclass package should only serve to make it easier for the author to do what they want. It absolutely should not impose the library author’s philosophy on others. With that in mind, the best solution here is to allow flexibility in our API.
There are a few ways we could do this. If our library was already in use then changing the API would be a bad idea. In this case it isn’t but I think the best solution here does not change the API anyway. Let’s use the functional options pattern to add options to the existing WrapAs() func.
Let’s offer 3 ways to wrap the error with a class:
- Always add the new class wrapping (default, and current behavior)
- Only add a class wrapping if the error isn’t already wrapped (ie the errclass is
Unknown) - Only add a class wrapping if the new class is a higher severity than the existing class
Functional Options Boilerplate#
type wrappingRestriction int
const (
wrappingRestrictionNone wrappingRestriction = iota
wrappingRestrictionOnlyUnknown
wrappingRestrictionOnlyMoreSevere
)
type wrapOptions struct {
restriction wrappingRestriction
}
type WrapOption func(opt *wrapOptions)The Options funcs#
func WithUnrestricted() WrapOption {
return func(opt *wrapOptions) {
opt.restriction = wrappingRestrictionNone
}
}
func WithOnlyUnknown() WrapOption {
return func(opt *wrapOptions) {
opt.restriction = wrappingRestrictionOnlyUnknown
}
}
func WithOnlyMoreSevere() WrapOption {
return func(opt *wrapOptions) {
opt.restriction = wrappingRestrictionOnlyMoreSevere
}
}Finally, the new WrapAs func#
func WrapAs(err error, class Class, opts ...WrapOption) error {
if err == nil {
return nil
}
// Apply options
options := wrapOptions{}
for _, opt := range opts {
opt(&options)
}
currentClass := GetClass(err)
switch options.restriction {
case wrappingRestrictionOnlyUnknown:
if currentClass == Unknown {
return xerrors.Extend(class, err)
}
return err
case wrappingRestrictionOnlyMoreSevere:
if class > currentClass {
return xerrors.Extend(class, err)
}
return err
default:
return xerrors.Extend(class, err)
}
}Admittedly, the functional options pattern creates rather verbose code for what it does. However the benefit of the variadic parameter is that if there was already code that called the old WrapAs(error, Class) signature, it would still work. In fact, it would work in exactly the same way since we have chosen the default behavior to match our previous behavior. Note that if WrapAs was a receiver func, then this does change the signature, which would affect any interfaces - however that is not the case here.
Gotcha: Joined Errors#
xerrors package and all subpackages, not just errclass.I wanted to talk about this in the previous article, but decided it would make more sense in the context of a concrete implementation like this one.
Using errors.Join
with our ExtendedError (and thus all packages that use it, like the errclass package) will result in a less-than-ideal outcome. The problem here is that errors.Join returns the internal type *errors.joinError which does not implement slog.LogValuer (source code
).
Even if it did implement slog.LogValuer, this would open a whole new can of worms for us, as now we would need to support an error tree, and not just an error chain. That is a much harder problem than it appears.
Consider this “simple” Unjoin() func:
func Unjoin(err error, recursive bool) []error {
if err == nil {
return nil
}
joined, ok := err.(interface{ Unwrap() []error })
if !ok {
return []error{err}
}
if !recursive {
return joined.Unwrap()
}
var result []error
for _, child := range joined.Unwrap() {
result = append(result, Unjoin(child, true)...)
}
return result
}At first glance, this does what we would need in order to start building out support. Unfortunately, this code is incorrect. Consider this:
err := xerrors.Extend(data, errors.Join(err1, err2))If you call Unjoin(err) here, it would return only []errors{err} since ExtendedError[data] does not implement Unwrap() []error. Even if it did, and we got []errors{err1, err2} as intended, that result is now missing the data that was added to the outer joined error.
Like I said, it’s complicated.
The good news is that (in my experience) errors.Join is seldom used. The bad news is we are trying to create a library that could be used by anyone in any way - including these odd-looking edge-cases.
For now at least, I’m going to accept that we don’t properly support joined errors.
Next Steps#
The complete code for our errclass package can be found on github . Note that the code will likely differ from what was presented here as it is expanded and improved over time.
Related Reading#
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
