Go errors: Is() and As() claim to be recursive, is there any type that implements the error interface and supports this recursion - bug free?
Asked Answered
T

4

3

Everywhere I look, the "way" to "wrap" errors in Go is to use fmt.Errof with the %w verb

https://go.dev/blog/go1.13-errors

However, fmt.Errorf does not recursively wrap errors. There is no way to use it to wrap three previously defined errors (Err1, Err2, and Err3) and then check the result by using Is() and get true for each those three errors.

FINAL EDIT:

Thanks to @mkopriva's answer and comments below it, I now have a straightforward way to implement this (although, I am still curious if there is some standard type which does this). In the absence of an example, my attempts at creating one failed. The piece I was missing was adding an Is and As method to my type. Because the custom type needs to contain an error and a pointer to the next error, the custom Is and As methods allows us to compare the error contained in the custom type, rather than the custom type itself.

Here is a working example: https://go.dev/play/p/6BYGgIb728k

Highlights from the above link

type errorChain struct {
    err  error
    next *errorChain
}

//These two functions were the missing ingredient
//Defined this way allows for full functionality even if
//The wrapped errors are also chains or other custom types

func (c errorChain) Is(err error) bool { return errors.Is(c.err, err) }

func (c errorChain) As(target any) bool { return errors.As(c.err, target) }

//Omitting Error and Unwrap methods for brevity

func Wrap(errs ...error) error {
    out := errorChain{err: errs[0]}

    n := &out
    for _, err := range errs[1:] {
        n.next = &errorChain{err: err}
        n = n.next
    }
    return out
}

var Err0 = errors.New("error 0")
var Err1 = errors.New("error 1")
var Err2 = errors.New("error 2")
var Err3 = errors.New("error 3")

func main() {
    //Check basic Is functionality
    errs := Wrap(Err1, Err2, Err3)
    fmt.Println(errs)                            //error 1: error 2: error 3
    fmt.Println(errors.Is(errs, Err0))           //false
    fmt.Println(errors.Is(errs, Err2))           //true
}

While the Go source specifically mentions the ability to define an Is method, the example does not implement it in a way that can solve my issue and the discussion do not make it immediately clear that it would be needed to utilize the recursive nature of errors.Is.

AND NOW BACK TO THE ORIGINAL POST:

Is there something built into Go where this does work?

I played around with making one of my own (several attempts), but ran into undesirable issues. These issues stem from the fact that errors in Go appear to be compared by address. i.e. if Err1 and Err2 point to the same thing, they are the same.

This causes me issues. I can naively get errors.Is and errors.As to work recursively with a custom error type. It is straightforward.

  1. Make a type that implements the error interface (has an Error() string method)
  2. The type must have a member that represents the wrapped error which is a pointer to its own type.
  3. Implement an Unwrap() error method that returns the wrapped error.
  4. Implement some method which wraps one error with another

It seems good. But there is trouble.

Since errors are pointers, if I make something like myWrappedError = Wrap(Err1, Err2) (in this case assume Err1 is being wrapped by Err2). Not only will errors.Is(myWrappedError, Err1) and errors.Is(myWrappedError, Err2) return true, but so will errors.Is(Err2, Err1)

Should the need arise to make myOtherWrappedError = Wrap(Err3, Err2) and later call errors.Is(myWrappedError, Err1) it will now return false! Making myOtherWrappedError changes myWrappedError.

I tried several approaches, but always ran into related issues.

Is this possible? Is there a Go library which does this?

NOTE: I am more interested in the presumably already existing right way to do this rather than the specific thing that is wrong with my basic attempt

Edit 3: As suggested by one of the answers, the issue in my first code is obviously that I modify global errors. I am aware, but failed to adequately communicate. Below, I will include other broken code which uses no pointers and modifies no globals.

Edit 4: slight modification to make it work more, but it is still broken

See https://go.dev/play/p/bSytCysbujX

type errorGroup struct {
    err        error
    wrappedErr error
}

//...implemention Unwrap and Error excluded for brevity

func Wrap(inside error, outside error) error {
    return &errorGroup{outside, inside}
}

var Err1 = errorGroup{errors.New("error 1"), nil}
var Err2 = errorGroup{errors.New("error 2"), nil}
var Err3 = errorGroup{errors.New("error 3"), nil}

func main() {
    errs := Wrap(Err1, Err2)
    errs = Wrap(errs, Err3)
    fmt.Println(errs)//error 3: error 2: error 1
    fmt.Println(errors.Is(errs, Err1)) //true
    fmt.Println(errors.Is(errs, Err2)) //false <--- a bigger problem
    fmt.Println(errors.Is(errs, Err3)) //false <--- a bigger problem
}

Edit 2: playground version shortened

See https://go.dev/play/p/swFPajbMcXA for an example of this.

EDIT 1: A trimmed version of my code focusing on the important parts:

type errorGroup struct {
    err        error
    wrappedErr *errorGroup
}

//...implemention Unwrap and Error excluded for brevity

func Wrap(errs ...*errorGroup) (r *errorGroup) {
    r = &errorGroup{}
    for _, err := range errs {
        err.wrappedErr = r
        r = err

    }
    return
}

var Err0 = &errorGroup{errors.New("error 0"), nil}
var Err1 = &errorGroup{errors.New("error 1"), nil}
var Err2 = &errorGroup{errors.New("error 2"), nil}
var Err3 = &errorGroup{errors.New("error 3"), nil}

func main() {
    errs := Wrap(Err1, Err2, Err3)//error 3: error 2: error 1
    fmt.Println(errors.Is(errs, Err1)) //true

    //Creating another wrapped error using the Err1, Err2, or Err3 breaks the previous wrap, errs.
    _ = Wrap(Err0, Err2, Err3)
    fmt.Println(errors.Is(errs, Err1)) //false <--- the problem
}
Tso answered 28/3, 2022 at 17:16 Comment(16)
"Since errors are pointers" - your errors are pointers, but error is an interface and can be satisfied by a value or a pointer. Your code is "broken" because you're modifying package variables; it works exactly as you'd expect given how it's implemented. Wrapping global error values is not at all how errors are intended to be used. You're meant to use a new error value to wrap another error, not to modify a global error value. This is explained in the documentation you linked at the top of your post.Scutiform
Your Wrap function is broken. And you should include any relevant code (in this case just the Wrap function's enough to see the problem) in the question. Links to outside are good but second rate.Register
@Scutiform I ran into similar issues implementing the error interface with a value instead. I decided to post the one I did as the standard library implements it with a pointer as well. Can you then explain how to use Is() and As() with their recursive abilities, which are highlighted in the documentation as a selling point?Tso
@Register my code is linked at the bottom on the go playground, should I also post the code in the post?Tso
@S.Melted only the problematic code, there's not need for all that code, or even for all that text, it makes the actual problem hard to find. Like "where's Waldo".Register
@S.Melted links to outside are good but second rate. Code inside the question is the best thing you can do here.Register
@S.Melted really a minimal reproducible example is what you should be striving for. With emphasis on the "minimal".Register
"However, fmt.Errorf does not recursively wrap errors." Makes no sense to me. What is "recursed wrapping"? How would a gift look like that has been wrapped recursively?Aroid
@Aroid my apologies for poor word choice. What I mean is that an error can be wrapped by an error which can be wrapped by an error.....so that the recursive checking by Is() and As() can check each of the errors for a matchTso
@Register I have now also updated my playground link to be more minimalTso
@Register I have added an alternate, but also broken, attempt which avoids the obvious criticism of my first code. (In fact, this is more like my original, failed attempt). Again, I am more interested in an example of errors which can use Is and As recursively, as they are both intended and touted to do.Tso
@S.Melted does the example I've provided as a comment to Adrian's answer not satisfy your requirement?Register
@Register It does. Thank you. Am I correct in understanding that defining the Is() Method was the key I was missing?Tso
@Register if you post an answer, I will mark it as accepted. Both my attempts at coding and asking a question got off with a rough start, but you reeled me in thank you. Next time I post I will 1) be more brief with code and 2) include my substantially different attempts, rather than just my current one.Tso
@S.Melted 1) brief and straight to the point, and with code is good 2) one attempt is enough (IMO), your best attempt 3) if possible, code should include example input and desired output (essentially MCVE) 4) and lastly, as a pre-emptive defensive measure, if you think you are asking something that may be considered unusual, it may help trying to explain why you want to do what you want to do. Why you think you need to take the Y approach to solve X. en.wikipedia.org/wiki/XY_problemRegister
@S.Melted the Is method is not necessary; the default implementation will look for identity in any wrapped error. Is can be implemented if you want errors.Is to behave in some other way (like comparing an error code field).Scutiform
R
2

You can use something like this:

type errorChain struct {
    err  error
    next *errorChain
}

func Wrap(errs ...error) error {
    out := errorChain{err: errs[0]}

    n := &out
    for _, err := range errs[1:] {
        n.next = &errorChain{err: err}
        n = n.next
    }
    return out
}
func (c errorChain) Is(err error) bool {
    return c.err == err
}

func (c errorChain) Unwrap() error {
    if c.next != nil {
        return c.next
    }
    return nil
}

https://go.dev/play/p/6oUGefSxhvF

Register answered 28/3, 2022 at 18:36 Comment(11)
Thank you. I was unable to find this on my own because all of the search results focused on fmt.Errorf, which does not do this. And I clearly lacked the programming chops to tackle it myself. Thank you for the programming and posting tips. Adding func (c errorChain) Is(err error) bool to my original code (not the code I originally posted), fixed my issue. Lacking that insight, I went from bad to worseTso
For anyone else like me, in retrospect, this is clearly referenced in the Go source, cs.opensource.google/go/go/+/refs/tags/go1.18:src/errors/… with an example in the source at cs.opensource.google/go/go/+/refs/tags/go1.18:src/syscall/…Tso
You do not have to define an Is method.Scutiform
@Scutiform I am all ears and eager to learn. Can you show me how to write errors which utilize the recursive nature of errors.Is without defining an Is method? I was unable to do so, but was able to fix a broken attempt just by adding that in. I would love to learn more, if you have time to write something up :)Tso
errors.Is will recursively check equality. If any wrapped error == the value passed to compare against, errors.Is will return true. If any wrapped error implements an Is method, that will be invoked if the equality test fails. An error is considered to match a target if it is equal to that target or if it implements a method Is(error) bool such that Is(target) returns true.Scutiform
@S.Melted here is an example where Is works without an Is method: go.dev/play/p/mFB-8nIAaDlScutiform
@Scutiform I looked at your example, perhaps I am misunderstanding. But I am certainly miscommunicating. Suppose an error occurs multiple function calls deep. Each function should be able to wrap the returned error with its own. The main function should be able to use Is to test if the returned error is any of those errors. Is includes that functionality, but fmt.Errorf and your example, as far as I can tell, do not. In other words, if Err is an error in which Err1 wraps Err2 which wraps Err3. Is(Err, Err1), Is(Err, Err2), and Is(Err, Err3), should all be true.Tso
This seems like a use case for As, not Is. If you must use Is, and you must use it for error values other than the root-most value, then you have to implement your own Is method to check something other than identity. But wrapping A in B in C, generally you don't use Is to check error B's value, you use As to check its type.Scutiform
@Scutiform Using As that way certainly has a huge use case, but runs into the same issue. If I have Err that is Err1 wrapping Err2 which wraps Err3, and if they all have different types, As should return true for As(Err, &eType1), As(Err, &eType2), and As(Err, &eType3). But fmt.Errorf and similar don't construct wrapped errors that support this.Tso
@Register I slightly edited your Is method so that it will work even if one of the members of the errorChain is, itself, an errorChain (or some other custom error type). go.dev/play/p/8kCmDLD1xEFTso
@Register using return errors.Is(c.err, err) for the Is method allows it to both check a nested errorChain within an errorChain, but also errors made by fmt.Errorf insde an errorChainCeric
S
1

Your code modifies package-global error values, so it is inherently broken. This defect has nothing to do with Go's error handling mechanics.

Per the documentation you linked, there are two error-handling helpers: Is, and As. Is lets you recursively unwrap an error, looking for a specific error value, which is necessarily a package global for this to be useful. As, on the other hand, lets you recursively unwrap an error looking for any wrapped error value of a given type.

How does wrapping work? You wrap error A in a new error value B. A Wrap() helper would necessarily return a new value, as fmt.Errorf does in the examples in the linked documentation. A Wrap helper should never modify the value of the error being wrapped. That value should be considered immutable. In fact, in any normal implementation, the value would be of type error, so that you can wrap any error, rather than just wrapping concentric values of your custom error type in each other; and, in that case, you have no access to the fields of the wrapped error to modify them anyway. Essentially, Wrap should be roughly:

func Wrap(err error) error {
    return &errGroup{err}
}

And that's it. That's not very useful, because your implementation of errGroup doesn't really do anything - it provides no details about the error that occurred, it's just a container for other errors. For it to have value, it should have a string error message, or methods like some other error types' IsNotFound, or something that makes it more useful than just using error and fmt.Errorf.

Based on the usage in your example code, it also looks like you're presuming the use case is to say "I want to wrap A in B in C", which I've never seen in the wild and I cannot think of any scenario where that would be needed. The purpose of wrapping is to say "I've recieved error A, I'm going to wrap it in error B to add context, and return it". The caller might wrap that error in error C, and so on, which is what makes recursive wrapping valuable.

For example: https://go.dev/play/p/XeoONx19dgX

Scutiform answered 28/3, 2022 at 17:46 Comment(7)
Thank you. This was my umpteenth effort. Which was obviously broken in the way you describe. Regardless of my approach, I was unable to implement the error interface in a way that the recursive nature of Is() and As() were used. Can you point me to any working code which does so? I assume since Is and As were made to be recursive, there has to be a working example of an error wrapped in an error wrapped in an error, otherwise, why make it recursive at all?Tso
So, I have an updated attempt. go.dev/play/p/HJKV2xanMxZ Here my error type is not a pointer, but runs into the larger issue of not being able to use Is or As effectively at all. The code is now less functional. I will update my post as well.Tso
A further, tweaked update go.dev/play/p/bSytCysbujXTso
@S.Melted go.dev/play/p/7INZhoBcoa8Register
@Register thank you. Am I correct in understanding that the key here was defining the Is() method for the new type?Tso
@S.Melted the key is not to modify the passed in variables if you expect them to represent some value that does not change regardless of how many times it's passed to Wrap. The Is() method is important though.Register
Edited to add working example.Scutiform
P
1

Instead of chaining/wrapping, you will "soon" (Go 1.20, as seen in Go 1.20-rc1 in Dec. 2022) be able to return a slice/tree of errors.
(In the meantime, mdobak/go-xerrors is a good alternative)


The release note explains:

Wrapping multiple errors

Go 1.20 expands support for error wrapping to permit an error to wrap multiple other errors.

An error e can wrap more than one error by providing an Unwrap method that returns a []error.

The errors.Is and errors.As functions have been updated to inspect multiply wrapped errors.

The fmt.Errorf function now supports multiple occurrences of the %w format verb, which will cause it to return an error that wraps all of those error operands.

The new function errors.Join returns an error wrapping a list of errors.


That comes from:

proposal: errors: add support for wrapping multiple errors

Background

Since Go 1.13, an error may wrap another by providing an Unwrap method returning the wrapped error.
The errors.Is and errors.As functions operate on chains of wrapped errors.

A common request is for a way to combine a list of errors into a single error.

Proposal

An error wraps multiple errors if its type has the method

Unwrap() []error

Reusing the name Unwrap avoids ambiguity with the existing singular Unwrap method.
Returning a 0-length list from Unwrap means the error doesn't wrap anything.

Callers must not modify the list returned by Unwrap.
The list returned by Unwrap must not contain any nil errors.

We replace the term "error chain" with "error tree".

The errors.Is and errors.As functions are updated to unwrap multiple errors.

  • Is reports a match if any error in the tree matches.
  • As finds the first matching error in a inorder preorder traversal of the tree.

The errors.Join function provides a simple implementation of a multierr.
It does not flatten errors.

// Join returns an error that wraps the given errors.
// Any nil error values are discarded.
// The error formats as the text of the given errors, separated by newlines.
// Join returns nil if errs contains no non-nil values.
func Join(errs ...error) error

The fmt.Errorf function permits multiple instances of the %w formatting verb.

The errors.Unwrap function is unaffected: It returns nil when called on an error with an Unwrap() []error method.

Why should this be in the standard library?

This proposal adds something which cannot be provided outside the standard library: Direct support for error trees in errors.Is and errors.As.

Existing combining errors operate by providing Is and As methods which inspect the contained errors, requiring each implementation to duplicate this logic, possibly in incompatible ways.
This is best handled in errors.Is and errors.As, for the same reason those functions handle singular unwrapping.

In addition, this proposal provides a common method for the ecosystem to use to represent combined errors, permitting interoperation between third-party implementations.

So far (Sept. 2022) this proposal seems a likely accept has been accepted!

CL 432575 starts the implementation.

Purpleness answered 17/9, 2022 at 21:5 Comment(0)
C
0

There arr several approaches but there is one thing that you should keep in mind: if you have multiple errors, you may need to handle it as a slice of errors

For instance, imagine you need to check if all errors are the same, or there is at least one error of certain type you can use the snippet below.

You can extend this concept or use some existing library to handle multierrors

type Errors []error

func (errs Errors) String() string {
  …
}

func (errs Errors) Any(target error) bool{
    for _, err := range errs {
        if errors.Is(err,target) {
            return true
        }
    }
    return false
}

func (errs Errors) All(target error) bool{
    if len(errs) == 0 { return false }
    
    for _, err := range errs {
        if !errors.Is(err,target) {
            return false
        }
    }
    return true
}
Covey answered 28/3, 2022 at 19:40 Comment(1)
Definitely relevant information. I have used arrays in the past. What lead me down this path was that the Is and As documentation specifically mention their recursive nature, yet I could find no code where their recursive nature was used. Which is quite strange - especially given that this feature is specifically highlighted.Tso

© 2022 - 2024 — McMap. All rights reserved.