go: proposal: errors: add With(err, other error) error
Background
Right now, the only way to wrap an error in the standard library is with fmt.Errorf which means that all you can do to an error is add text to its .Error() output.
When you receive an error from a function and want to return it up the stack with additional information, you have 3 options
- you can return some other error, which loses the original error’s context
- you can wrap the error with
fmt.Errorf, which just adds textual output, and therefore isn’t something callers can programmatically check for - you can write a complicated error wrapping struct that contains the metadata you want to check for, and behaves correctly for
errors.Is,.As, and.Unwrapto allow callers to access the underlying cause of the error.
Proposal
This proposal offers a much easier way to do number 3, by implementing a new function in the stdlib errors package that supports wrapping any error with any other error, such that they are both discoverable with errors.Is and errors.As.
// With returns an error that adds flag to the list of errors that can be
// discovered by errors.Is and errors.As.
func With(err, flag error) error {
if err == nil {
return nil
}
if flag == nil {
return err
}
return flagged{error: err, flag: flag}
}
type flagged struct {
flag error
error
}
func (f flagged) Is(target error) bool {
return errors.Is(f.flag, target)
}
func (f flagged) As(target interface{}) bool {
return errors.As(f.flag, target)
}
func (f flagged) Unwrap() error {
return f.error
}
This means that: errors.With(err, flag).Error() returns err.Error() errors.Unwrap(With(err, flag)) returns err errors.Is(With(err, flag), target) is the same as calling errors.Is(flag, target) || errors.Is(err, target) errors.As(With(err, flag), target) is the same as calling errors.As(flag, target) || errors.As(err, target)
Why This Approach?
By reusing the existing error wrapping pattern, we make the smallest possible change to the standard library, while allowing the broadest applicability of the functionality. We introduce no new interfaces or concepts. The function is general enough to support many different use cases, and yet, its use would be invisible to anyone who is not interested in using error wrapping themselves. It imposes no added burden on third parties that check errors (beyond what is already standard with fmt.Errorf wrapping), and can be ignored by authors producing errors, if that is their wish.
Use Cases
The main use case for this function is incredibly common, and I’ve seen it in basically every go application I’ve ever written. You have a package that returns a domain-specific error, like a postgres driver returning pq.ErrNoRows. You want to pass that error up the stack to maintain the context of the original error, but you don’t want your callers to have to know about postgres errors in order to know how to deal with this error from your storage layer. With the proposed With function, you can add metadata via a well-known error type so that the error your function returns can be checked consistently, regardless of the underlying implementation.
This kind of error is often called a Sentinel error. fs.ErrNotExist is a good example of a sentinel error that wraps the platform-specific syscall error.
// SetUserName sets the name of the user with the given id. This method returns
// flags.NotFound if the user isn't found or flags.Conflict if a user with that
// name already exists.
func (st *Storage) SetUserName(id uuid.UUID, name string) error {
err := st.db.SetUser(id, "name="+name)
if errors.Is(err, pq.ErrNoRows) {
return nil, errors.With(err, flags.NotFound)
}
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Constraint == "unique_user_name" {
return errors.With(err, flags.Conflict)
}
if err != nil {
// some other unknown error
return fmt.Errorf("error setting name on user with id %v: %w", err)
}
return nil
}
This keeps the error categorization very near to the code that produces the error. Nobody outside of SetUserName needs to know anything about postgres driver errors.
Now in the API layer, you can translate this error to an HTTP error code trivially:
func (h *Handlers) HandleSetName(w http.ResponseWriter, r *http.Request) {
name, id := getNameAndID(r)
err := h.storage.SetUserName(id, name)
if err != nil {
handleError(err, w)
return
}
// other stuff
}
func handleError(err error, w http.ResponseWriter) {
switch {
case errors.Is(err, flags.NotFound):
http.Error(w, 404, "not found")
case errors.Is(err, flags.Conflict):
http.Error(w, 409, "conflict")
default:
// other, uncategorized error
http.Error(w, 500, "internal server error")
// probably log it, too
}
}
This code doesn’t know anything about postgres. It uses the standard errors.Is to check for errors it knows about. But if it then decides to log that error, it has full access to the original error’s full context if it wants to dig into it.
This code is very insulated from any implementation changes to the storage layer, so long as it maintains its API contract by continuing to categorize the errors with the same error flags using errors.With.
What This Code Replaces
Without code like this, the handleError functions above would have to do something like this itself:
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Constraint == "unique_user_name" {
http.Error(w, 409, "conflict")
return
}
Not only is that super gross to be doing in the API layer, but it would silently break if someone decided to change database or even just the name of the constraint, and didn’t realize that this far away method was digging deep into that error. I’ve seen and written this kind of code many times in my career, and it feels really wrong, but without stdlib support, it’s hard to know what the alternative is.
Production Experience
I used this kind of error wrapping at Mattel for a long time, and now I’m introducing it to GitHub’s codebase. It VASTLY improved our error handling in both cases, with very little cognitive or processing overhead. It has made it much easier to understand what an error return really means. It has made writing correctly-behaving API code much simpler by breaking the coupling between two disparate parts of the system.
Community Examples
- https://pkg.go.dev/go.uber.org/multierr
- https://pkg.go.dev/github.com/hashicorp/go-multierror
- https://pkg.go.dev/github.com/go-openapi/errors#CompositeError
- https://pkg.go.dev/v2ray.com/core/common/errors#Combine
- https://pkg.go.dev/github.com/hashicorp/errwrap#Wrap
- https://pkg.go.dev/github.com/juju/errors#Wrap
- https://pkg.go.dev/github.com/cosmos/cosmos-sdk/types/errors#Wrap
- https://pkg.go.dev/github.com/cockroachdb/errors
Conclusion
Adding this method to the standard library would encourage the use of this pattern. Packages using this pattern have better-defined and more stable contracts about how callers can programmatically respond to different categories of errors, without having to worry about implementation details. This is especially true of application code, where error categorization has traditionally been difficult.
Special thanks to @rogpeppe for his suggested improvement to my original error flags idea.
About this issue
- Original URL
- State: closed
- Created 2 years ago
- Reactions: 105
- Comments: 119 (93 by maintainers)
I understand that the bar is high to put something new in the standard library, but I think the fact that there are at least 3 prior proposals about this (and probably more, I know I’ve seen multierror support requested a few times) speaks to how many people need this behavior.
The comment that it needs to exist in an external library seems to be satisfied by these:
As for why it needs to be in the standard library, I think because it is not an obvious or easy function to create, and it’s hard to write correctly. Having a function like this in the errors package gives everyone an answer to “how do I decorate an existing error with more metadata without losing the original error information?” And the answer is right there next to the rest of the error handling code. Right now, there’s no real answer to that.
Just as we have put error wrapping code into the stdlib to be able to decorate an error’s message without losing the original error, adding this function could make it so that we can decorate an error with more than just more text. This will help avoid leaking implementation details and distant coupling that can happen when your only options are “add some text to the existing error” or “throw away that error and return your own”.
I’m not sure this means. Discoverability is worse than what? The error returned does implement errors.Is. And adding metadata at the point where an error is first encountered in your code is the best place to do that. That’s where you have the most context about what an error means and what it represents.
@jimmyfrasche
Yeah, basically? u.other could contribute to the returned error’s string. It could have
I kinda like not doing it by default, in case you don’t want the added error to change the error string, and you can always use fmt.Errorf to add the string to the wrapped error if you wanted to, e.g.:
@rittneje
Your way is 10 lines of code (though you could get it down to 6) for every single sentinel error you need. Experience shows us that people generally are just too lazy to do that. In addition, it’s not an obvious pattern. If there’s errors.With right there in the package, it’s much easier to find and figure out how to use, especially if there’s a nice piece of example code. And then your sentinel errors are literally just a single line.
What I want is to solve this:
If you’re calling LoadConfig, how do you differentiate programmatically between “config not found” and other errors, so that you can handle that error differently?
Without errors.With, you have two choices…
1.) look for the string “can’t find config” in err.Error() (gross) 2.) call os.IsNotExist(err) on the returned error. (also gross, but less obviously so)
Most people do #2. The problem is that calling os.IsNotExist is causing your code to rely on the hidden implementation details of LoadConfig. That method intentionally hides how it stores configs. If it changes so it’s getting the config from a database instead of off of disk, os.IsNotExist will stop detecting the not found condition, and your code that is supposed to do something in that case won’t trigger.
If you instead write this code with errors.With, you can wrap the error with a sentinel error in the not found branch. Then it is detectable by callers, without losing the possibly valuable information in the underlying error that would happen if you just returned the sentinel error instead:
Now when you want to check for when the config is not found, you just do
edit: this implementation doesn’t actually work 😦 edit2: But this does: https://go.dev/play/p/MkXOS_aXtM0 edit3: Now without causing unecessary unwrappings: https://go.dev/play/p/K99JW49Js-G
Actually, I think @jimmyfrasche has the key here. You can replace the whole underlying implementation with this:
That’s it. That’s the implementation. It logically stacks the second error on top of the first, and lets Unwrap iterate through both, so now Is and As work through the same list as Unwrap (and thus we don’t even need to implement those interfaces).
30 lines of code that had two subtle bugs in it that I missed even after writing the initial tests… and I’ve written almost this exact library before. The first time I wrote it, it was a couple days of work to wrap (heh) my head around errors.Is and errors.As and what the interfaces really expected. And even then, since I was new to the idea, I came up with something much more complicated than this.
One of the major benefits of having this in the standard library is that it makes the functionality discoverable. By having it in the standard library, we’re saying “hey, this is a thing you might want to do”. We give devs a standard and easy way to let people programmatically check their errors, without having to throw away the underlying implementation errors.
If I understand this proposal correctly, it doesn’t really capture the main cases in which wrapping errors are currently used. When I look at the cases in the standard library, I don’t see cases where we have two types that implement
errorand want to combine them. What I see are structs with a fieldErr error, withErrorandUnwrapmethods. TheErrormethod returns some string that incorporatesErr.Error(), and theUnwrapmethod returnsErr.That is, the pattern is
Based on that perhaps we should consider
Now if a general concept like “not found” is meaningful, and does not require any additional information, it becomes a type rather than a value.
We can also move general cases like
os.SyscallError, which can become@natefinch Again, this is only because you are trying to use sentinel errors instead of error types.
The other nice thing is if you do want the underlying error to be opaque (so clients cannot use
os.IsNotFoundor the like), you can still do so by not implementingUnwrapand makingErrprivate.I have some support for this proposal, but also some concerns and at least one design question.
In support
I can see the utility of something like this. I believe there is a real challenge to maintain the discipline to raise the abstraction level of errors as they are returned up the call stack while at the same time not obliterating the useful debugging information contained in the original lower level errors. In particular, API services I’ve worked on—which have typically used github.com/go-kit/kit packages and design patterns—often have error handling complexities that fit the use cases described in this proposal. Here is some code taken from one of the Go kit examples, it is akin to the
handleErrorfunction shown above.https://github.com/go-kit/examples/blob/e18c82edc2f9c7de3aff9748751d63270c052c1b/shipping/booking/transport.go#L181-L195
That code predates
errors.Is, but a newly written one would likely use it or maybeerrors.As. Go kit has an FAQ about error encoding that hints at some of the dilemmas involved. What I always found unsettling was that despite the nice degree of decoupling that a typical Go kit application has between the various pieces, these error handling functions were the most likely to have the coupling skeletons hidden in them.I believe a well known way to transform a system-domain error into a business-domain error without losing the ability to log the original system-domain details for triaging and debugging purposes would add value. I believe that’s what this proposal is aiming for.
Concerns
I am not confident the approach taken here is general enough for the standard library. https://github.com/golang/go/issues/52607#issuecomment-1112666938 references several existing packages providing some form of composite errors. Are we seeing convergence on a common approach across those implementations? I’ve always been reluctant to use any of those that I’ve researched in the past because some aspect of their semantics wasn’t quite what I needed at the time.
Design Questions
A portion of the
errors.Asdocs for reference:Restating this proposal:
The docs for
Withdo not address which of the two errors thatAssetstargetto when it returnstrue. The choice becomes ambiguous if botherrandotherare assignable to the value pointed to by target. The only hint is the last sentence which says thatUnwrapreturnserrwhich suggests that perhapserris given priority overotherin general and one might guess thatAswould follow suit. But the linked implementation prioritizesotherin itsAsmethod:What is the rationale for
UnwrapandAsto behave “differently” here? I also wonder how weird this might get with multiple layers ofWithwrappings.@rsc I guess I don’t understand this comment. It already has been implemented by many individuals and organizations. What evidence of usefulness is the proposal review group looking for in particular?
Would it be a viable option to add something like this to golang.org/x/exp/ to gauge its uptake? I think having a semi-official repo would do a lot to consolidate users of various existing libraries across the community.
This isn’t quite true, it turns out:
https://go.dev/play/p/e2_pY73AsEw
Prints:
As defined in this proposal,
Withclearly creates a tree. The error chain of the returned error is (somewhat) a preorder or postorder traversal of that tree.Unwrapwraps the elements of the first branch traversed in a transient data structure, to maintain state of the tree traversal through successiveUnwrapcalls. This means thatUnwrapcan allocate, which makeserrors.Isunexpectedly expensive. I don’t thinkUnwrapshould ever allocate.The wrapping means that the error chain doesn’t quite contain a traversal of the tree: For some nodes, it traverses over wrapper errors that contain the actual error. There is no way to recover the wrapped error in this case. This seems surprising, given that
Withgoes to some lengths to preserve the chain structure of both its error parameters.The original Go 1.13 errors proposal included stack frame information in errors. While this was ultimately dropped from what made it into Go 1.13, it is possible that we may want to revisit this feature–making information about the stack frames associated with errors is a common desire. If we imagine associating each error with the stack location of its creation, the chain produced by
Withwill jump around in a confusing fashion. This is a point we should be careful about.The text of errors produced by
Withis not obvious. In the current proposal,With(err1, err2)produces an error with the text “err2: err1”. Why is the order of the errors reversed from the parameters? Is the ": " universally applicable here? What does a user do if this isn’t the text they want? One benefit of the%wformatting verb is that it makes the text of the wrapping error explicit and under the control of the user; the proposedWithloses that.Some possible directions for future refinement that I see:
func(wrap, is error) errorthat returns an error whereerr.Unwrap() == wrap,err.Error() == wrap.Error(), anderrors.Is(err, is) == truesuffice?Wrap(err1, err2), wouldfmt.Errorf("%w: %v", err2, err1)suffice? This produces an error which excludeserr1from the chain entirely. Since the motivating examples are all of cases where the inner error is intended be opaque to the user, this seems like a feature rather than a defect. (In one example from the original post, “Nobody outside of SetUserName needs to know anything about postgres driver errors.” If nobody needs to know anything about these errors, why should they be in the error chain?)%wverbs infmt.Errorf? Instead ofWrap(err1, err2),fmt.Errorf("%w: %w", err2, err1). This (I believe) could behave identically to the current proposal forWrap, while making the error text explicit.@ianlancetaylor
This case happens most often when an error is returned from another package and needs to be annotated as it is returned from this package. That happens all the time in application code, but it’s more rare in library code. The standard library is mostly library code. But what most Go developers write every day at work is application code.
There’s some wonky error handling in the
netpackage, but I haven’t sat down to figure out how much it could be simplified with either of our approaches. I think anywhere where you need to implement a type, then With is not a huge benefit (but it still is a benefit, because you don’t have to worry about implementing unwrapping).The problems with your generic wrapping errors are that 1.) You’re introducing a whole new interface that people have to care about, ErrorWrapper 2.) You have to implement a type for every error you want to check for.
1 is clearly adding complexity. 2… To check for these, you have to use errors.As, even though you don’t actually care about the value.
so you have to write this:
When you could have this with a sentinel value:
The latter is much easier to read IMO.
Also, if we have my union error implement
Error()like this:Then any two errors can be wrapped, and neither one has to implement Unwrap or ErrorWrap and you still get their error messages concatenated. And 99% of the time, the error output of these wrapping structures is just “mystring: errorString”.
Then you can make SyscallError a normal error type:
And
NewSyscallError(syscall string, err error) errorcould just beSo this is
fmt.Errorf("msg: %w", err)butmsgcan be a custom error typeerrdoes not contribute to the returned error’s.Error()Is that correct?
Challenge accepted: https://go.dev/play/p/be3AVWRAoTO
Based on the discussion above, this proposal seems like a likely decline. — rsc for the proposal review group
FWIW, some of us have been using “sentinel error” to refer to an
errorvalue intended to be tested for equality or witherrors.Is. For example,io.EOFis a sentinel error. Contrast*os.PathError, which is a type; or an error created in situ witherrors.New, which has a different identity each time it is created.If I write
errors.With(errors.With(err1, err2), errors.With(err3, err4)), then it seems to me that I have a tree of errors.If I write
errors.With(errors.With(MyError{"a"), io.EOF), errors.With(MyError("b"), io.EOF))and then pass that tovar me MyError; errors.As(err, &me)it seems ambiguous whether I getMyError("a")orMyError("b").In https://github.com/cockroachdb/errors, we have defined the following API:
From our experience using the above API, the extra sentinel error string should not be combined with the annotated error. Any form of combination (before, after, whatever) is utterly confusing because both errors typically have their own prefix, with context and sometimes multi-line text. In our API, the extra annotated sentinel is only visible when printing the error with
%+v.This implementation of
Unwrapallocates aunionfor every error in the chain below the firstunion. That seems very expensive;errors.Isanderrors.Asshouldn’t allocate.This proposal makes a fairly major change to the structure of wrapped errors. At the moment, we talk about the “error chain”–an ordered list of errors from outermost to innermost. Under this proposal, the chain becomes a tree. However, there is no way for users to reconstruct the tree structure;
union.Unwrappresents the tree as a linear list.The motivating examples are of APIs which wrap an underlying error such as
*pq.Errorwhile adding their own condition to it. I don’t understand why these examples wrap the underlying errors. For example, the proposal says of one example: “Nobody outside of SetUserName needs to know anything about postgres driver errors.” In this case, I think the underlying*pq.Errorshould not be part of the error chain, to prevent callers from being exposed to it.I do think there is space for a function which associates a new sentinel error with an error, to make it simpler to provide distinguishable error conditions without the need to write new error types. Perhaps something along these lines:
(I’m not sold on the name
Add, it’s just the first thing that came to mind that isn’tWith.)Addretains a linear chain of wrapped errors, does not introduce additional allocations toIs/As, and leaves the complexity ofIs/Asas O(N) where N is the depth of the chain.We at @symflower are in support of this proposal for a simple reason: we have implemented the same functionality (plus additions such as adding stack traces and arbitrary data without doing custom types again and again) for our whole code base of the company. The quote “The main use case for this function is incredibly common, and I’ve seen it in basically every go application I’ve ever written.” resonance so strongly with me because even before this company i have done the same thing in private and open source projects. We strongly believe that handling chains/lists of errors via the standard is what is missing in Go. There are a multitude of open source projects that implement such functionality and it is basically needed in every project that does error handling with adding context to their errors. The later is something that should IMHO done by everyone to improve discoverability and debuggability of programs. I have seen enough hours wasted of earth to know that more context for errors is a good thing.
For the reason why we did the same functionality: wrapping with fmt.Errorf, doing the textual wrapping, is no good for checking/switching for specific errors really fast and for actually using the meta information for other reasons than displaying it textually to the user.
Didn’t you just disprove this with https://go.dev/play/p/usV6mFRjxxb? 30 lines of very straight forward implementation.
I’d like to point out that nothing that consumes this needs to know how such an error was constructed; the consumer doesn’t even know if you used an stdlib implementation or 3rd party one.
As far as I know, the listed 3rd party projects largely predate
errors.Is/errors.Asand have dragged their ugly old APIs around, or are doing something else that’s much uglier than the examples in this proposal.I really do appreciate that this proposal explicitly does not attempt to display multiple errors. This is more like decorating the error with more data for programmatic access, which if not used is ignored in the final display. I find that much saner.
This proposal is essentially a duplicate of #50819 (errors.Mark), except that 50819 did not talk about errors.As (but could have). In particular it had the properties listed above:
except s/With/Mark/.
We declined Mark back in February saying:
It seems like the same is true here: not much has changed in the intervening three months. It seems like we should decline this one too.
context.Canceledandcontext.DeadlineExceededare two sentinel errors in the standard library that I don’t remember seeing mentioned in the discussion yet. That is making me think about https://github.com/golang/go/issues/51365, and what might have been or maybe could still be improved with the debuggability of context cancellation. 🤔Hmmm, OK, but I don’t see that in the proposal.
appendworks that way because it is building slice of elements. You can’t have a slice inside a slice. If you wanterrors.Withto build a linear chain of errors, then it seems to me that you need to say thaterrors.Withlooks at one of the arguments, checks to see whether it is the result of a earlier call toerrors.With, and, if so, unpacks it to recreate a linear chain of errors. If you don’t do that, then I don’t understand how you can wind up with anything other than a tree of errors.Maybe one of the implementations in the proposal does work that way. I’m not sure. But it’s not in the proposal text.
Even if it did I’m concerned that we would be left with a case where it’s not obvious to the reader which error is the “real” error and which error is the “sentinel” error. A sentinel and an error are two different kinds of things.
Here, for example, is a package that does something like this proposal, but uses sentinel codes: https://pkg.go.dev/google.golang.org/grpc/status
Of course it’s likely that sentinel values are diverse enough that it doesn’t make sense to put them in a widely shared package, but that doesn’t seem like an argument for pretending that sentinels and errors are the same kind of thing.
FWIW At Khan Academy, we follow exactly the pattern @natefinch describes, where we wrap every third party error with an appropriate sentinel error that allows us to use
errors.Is(err, errors.NotFoundKind)as an abstract concept, rather than an implementation detail specificerrors.Is(err, pgconn.PgError)that would need to be interrogated far from those details:I have also seen this pattern in the wild by many of the consumers of cockroachdb/errors and the other libraries that were mentioned above. Very few of the many, many bespoke implementations of
Wrap()are entirely correct, (h/t @knz ).If we cannot get support for adding this functionality to the stdlib outright, what about adding it to
x/exp/?Does this proposal support iterating the list of errors? As far as I can tell if you have a nested chain of With calls like
You can only test if ErrFoo, ErrBar or ErrBaz exist individually but it doesn’t seem possible to get them all as a slice.
While I agree with the argument “it’s simple to do”, I would suggest we’re overestimating engineers. I would prefer everyone be aware of a convention like in the aforementioned counter arguments, but I do not think it’s realistic and what follows is lower quality error handling and discovery than what could exist with this change.
If we intend to have ubiquitous adoption of the aforementioned convention, I would argue it makes sense to include in the stdlib?
Thank you for the proposal and looking forward to the discussion!
FWIW you don’t have to do anything special for
IsorAs. You just need to implementUnwrap. For example:Then the client can do:
The only reason a more complicated implementation is needed is if you specifically try to return another sentinel error.
I was thinking over the weekend about how some people on this thread (and elsewhere) want one error’s message or the other or both, and how that’s kinda hard to do nicely in one function… but I wonder if the answer is just to make 3 functions instead of one?
Only the result of .Error() changes. The behavior for all is identical to the original
Withproposal, as above.As much as I want it to be doable with a single function, I don’t think there’s a single behavior that’ll satisfy everyone, and I’m afraid that’ll be a dealbreaker.
I guess another option is that we always join the error messages, but also have an errors.Silent(err) that’ll squelch an error’s output… so you could write
return errors.With(errors.Silent(err), ErrNotFound) // silences the underlying error messageThat’s a bit wordy, but is a bit more explicit at the callsite than Wrap/With/Mask.
I think we are agreed that this is all about sentinels, such as
io.EOForfs.ErrNotExistakaos.ErrNotExist. And of course different applications will have their own sentinels as well.A sentinel isn’t a general error, but having a sentinel implement
erroris convenient for usingerrors.Isanderrors.As.But one thing that a regular
errorcan have but that a sentinel will not have is anUnwrapmethod. So I wonder whethererrors.Withshould panic if the sentinel argument has anUnwrapmethod. That would be a step toward ensuring that the call is used correctly. Or, we could perhaps implement this as a vet check.This doesn’t really make sense when
isis an error value (as is the case with sentinels). And presumably, if it’s an error type, whoever created the type can decide howUnwrap,AsandIsmight work.I like your proposal, but I find it most useful (and less questionable), when used with sentinels to tag errors. And sentinels are still how I mostly do errors.
I use sentinels in a couple of different ways:
Your proposal adds a very interesting new way of using sentinels:
I’d find this very useful. So much so that I’ll definitely use something along this lines, even if doesn’t get approved.
But, and I don’t want to needlessly constrain your proposal, for sentinels, you don’t really need to deal with recursive wrapping, unwrapping, tree traversal, etc, because for sentinels all you really want is
errors.Isto work on the sentinel.And I wish we had better support sentinels, because no matter how hard people try to kill them, the standard library is full of them, and they popup all the time.
I really don’t like reversing the arguments. It only makes sense if someone’s calling the function embedded in itself multiple times in the same line… which I think is going to be really rare.
I like having errors.With mimic the order of arguments in errors.Is and errors.As. errors.With(err, ErrNotFound) errors.Is(err, ErrNotFound)
Any method that would otherwise add data to an error would clearly have the original error first and the added data second. Why should this be different?
Cockroach’s Mark works that way. The flags package I wrote about here (and implemented at Mattel) worked that way. Juju’s Wrap works that way.
(Notably, Hashicorp’s Wrap does not work that way)
I know that fmt.Errorf works kind of the opposite, but that’s really an artifact of fmt being a string formatter that takes arguments to plug into a format string.
I think most people would write a general function that “adds some textual context to this error” as pkg.AddText(error, string) … and indeed, that’s how github.com/pkg/errors’s Wrap, cockroach’s WithDetails, and juju’s Annotate all work.
Most methods in Go follow a right to left assignment, just like a = b means assign b to a. That’s why io.Copy is Copy(dst, src), that’s why channel send and receive both point left in the direction of data flow. That’s why append is append(original, added). That’s why all the context functions are WithSomething(ctx, something).
FWIW, github.com/natefinch/wrap exists to be played around with in the playground. I tried to show a sorta real-world example here: https://go.dev/play/p/9Yr-3ojqwiK. (note that there it is called wrap.With).
As soon as I wrote
errors.Is(err, ErrNotFound)after having just writtenwrap.With(err, ErrNotFound), I was really sold on that order. I am sure that having the order reversed would absolutely trip people up all the time after writingerrors.Is(err, something)a million and one times, they’d use the same order for With, and then it would be backwards.@natefinch wrote:
I re-implemented this as a stack which makes this behavior a bit more obvious. (I’m not suggesting to use this implementation, union may have better performance)
For better or worse, we already have
fmt.Errorf("%w"). Removing a restriction on an existing feature is a smaller API change and doesn’t create a second way to wrap errors we would need to choose between.It would also allow more flexibility in how the error message gets composed. Here is a relevant experience report.
A couple years ago I wrote a custom error type for a private package at $work that had the same usage pattern as this proposal, specifically we wanted to export a sentinel error from the package for use with
errors.Is, but we also wanted to have the details of lower level errors in our logs. One difference was we had an additional piece of context we wanted in the error messages beyond the sentinel error and the lower level error. We also had a message parsing context we cared about.The package in question was for an RTSP client/server package. RTSP is interesting because both client and server may send requests on the connection and must be prepared to handle requests and send responses. We wanted the error messages returned from the message parsing layer to indicate whether a failure happened in a request or a response, but we didn’t need separate sentinels for each case.
If
fmt.Errorfsupported multiple%wverbs we could have written:If we had the version of
errors.Withthat included both errors in its error message then instead of a single call tofmt.Errorflike this;we would need to use both
errors.Withandfmt.Errorfas follows.BUT … for our real RTSP package we decided NOT to wrap the lower level errors in order not to leak those details in the package API and end up with some code depending on them. So the code we really wrote was equivalent to replacing the second
%win all of the above cases with a%vand we didn’t have a need forerrors.Withor support for more than one%winfmt.Errorf.I don’t know if this story supports this proposal or not, so that’s why I called it an experience report.
Speculating … If
errors.Withwas available when we wrote that code two years ago we may well have used it because it was convenient. Arguably, that would have resulted in a leakier abstraction. I wonder what people think about that aspect.If the point was simply to return a sentinel error with extra details in its message then
fmt.Errorf("%w: %v", sentinelErr, detailErr)is sufficient and no new feature is needed. So it seems like the ability to haveerrors.Ismatch BOTH the sentinel and the detail errors is the point, but is that good design practice?In your experience @natefinch, or others:
If you go that route maybe it is better written like this:
Of course that’s not allowed now, but if that’s the feature you want, maybe the proposal becomes to allow that and to extend the implementation of the non-exported
fmt.wrapErrorto handle more than one error.@ianlancetaylor The reason these sentinels are errors is because you can check them with the existing Is/As. You don’t have to differentiate between sentinels on errors and “normal” errors. They’re all just errors. Nobody needs to care if os.ErrNotExist is a sentinel added to a lower level error, or just a standalone error. Why have two ways to check basically the same thing?
Oh, and this also then lets you use any existing error as a sentinel on any other error, without either one needing any special code.
FWIW, I wrote a library exactly like your Sentinel code there. It’s what I wrote about in https://npf.io/2021/04/errorflags/ … and it works fine. BUt it means you have to know when to use sentinel.IsCode() vs. errors.Is. I like that this function lets you just always use errors.Is.
I guess that what I’m stuck on is that I don’t understand why the sentinel should have type
error. Will we ever see the sentinel all by itself?That said, admittedly
os.ErrNotExistis returned by itself and is also used as a sentinel. Callingerrors.Is(syscall.ENOENT, os.ErrNotExist)will returntrue.But for general purpose sentinel values we don’t need errors.
@neild I think
Iswould have to be implemented like this, or you could run into weird edge cases.I do still find this general approach confusing though. What the client will see in their logs is the original error, so it won’t be obvious they should be using some completely unrelated sentinel error in their checks.
Quick note, I updated the proposed implementation because it would end up running through the list of wrapped errors more times than it needed to. This is more correct, I think: https://go.dev/play/p/wHYEv5QrQXD (told you it was easy to write incorrectly! 😃
That’s a typo for #50819.
I think the post-mortem activity on https://github.com/golang/go/issues/47811 is interesting, and I think the best bet for getting something like errors.With/Mark into the standard library is to solve the multierror problem at the same time.
I was initially glad to see this proposal scaled back to its current form, because having a tree of errors makes
Unwrapdifficult to implement cheaply, as @neild observed.But now I worry that we’re leaving out the multiple-error use case. That’s clearly also important, as @tux21b pointed out.
I think it would be more general to support multiple errors. Basically,
would return an error that is
IsandAseach of the argument errors, in the order they appear. There is noUnwrap, but it is possible to recover the argument errors by callingErrors() []error. The error message is a concatenation of all the argument messages, with some separator.This is Tailscale’s design. The implementation is straightforward.
Except for the error message,
Withis then justMultiwith reversed arguments and withoutUnwrap. I think that’s an improvement, because the consensus seems to be that almost no one usesUnwrapdirectly anyway, and havingUnwrapreturn only the first argument ofWrapseems like a pitfall to me.The current proposal doesn’t specify the implementation of
Error(at least not in the initial comment; maybe it was added later?). But I gathered from the discussion that concatenating the messages is probably not what you want.So
Multihas the wrong argument order and the wrong error message forWith, but you could now easily writeWithin terms ofMultiand a type that implementedErrorhowever you wanted. But you couldn’t writeMultiin terms ofWith.That is an interesting suggestion. If I understand correctly, that would suppress the feeling that
errors.Withcould build error trees since one of its two arguments must not wrap anything.My understanding of why the stack traces from xerrors didn’t make it into Go 1.13 with errors.Is/As was that there was too many subtle details around situations where you did want a trace but didn’t want the error unwrapping chain and vice versa. (cc @mpvl) Introducing errors.With would create similar subtle problems around what parts of the error tree are or are not included. This thread has seen multiple different implementations mooted and you can poke around OSS and find other ways of doing things. I personally am fond of the idea of just overloading the Is() method to add the one sentinel, but I can see why it would be confusing that a sentinel error could be found by errors.Is but not errors.Unwrap.
Unlike everyone creating their own io.Reader/Writer variants, in this case, the error sentinels are application specific, so I don’t think there needs to be an ecosystem wide standard. Going from many people using pkg/errors to having a standard errors.As/Is was good because it meant that the standard library itself could use error wrapping. In this case though, I don’t think the standard library would end up marking sentinels, because the idea of errors.With is you’re converting from one system to another. You have a pgx.ErrNoRows and then you transform it into a domain.NotFound error, for example. The standard library is the core system, so it doesn’t need to translate its own errors into a second domain.
This proposal has been added to the active column of the proposals project and will now be reviewed at the weekly proposal review meetings. — rsc for the proposal review group
How does this proposal differ from #50819? It looks the same to me.
Yes.
These are precisely the questions raised by the proposal for
errors.With. If we have good answers for them there, then the same answers would presumably apply to multiple%wverbs.The tricky part isn’t flattening the tree of With(a, With(b, c)). The tricky part is flattening With(a, SomeWrapper(With(b, c))). I don’t see how you keep SomeWrapper while flattening the inner With.
@ianlancetaylor thanks for taking the time to clarify for me, this helps me understand your concerns.
This is exactly like writing
append(append(slice1, value2), append(slice3, value4)...)or evenappend(append(slice1, slice2...), append(slice3, slice4...)...)Well, except it’s prepend, not append. But the point is the structure of calls is the same, but both end up with a list as the output.
Unwrap, by definition, ensures that we have a linear list of errors. Imagine it’s called Next() instead of Unwrap(), and it just returns the next error in the list of errors. It’s the same contract.
In practice, I think it will be very uncommon to use a wrapped error as the second argument to the function. Based on how similar functionality has been used, I would guess that 99.9% of the time you’ll see
return errors.With(err, SomeSentinel)orreturn errors.With(err, applicationMetadata)where the second error is just a sentinel created with errors.New() or an application-specific structure that implements the error interface.The result of the above code is totally deterministic, and will always be “b”.
With, as written, is essentiallyerrors.Prepend(orig, err2), resulting in a logical list of errors like [err2, orig].In theory we could implement
Withas append instead, but I think that optimizes for the 0.1% case to the detriment of the 99.9% case.If
Withwas implemented as an append, then the above list of errors could be read left to right as["a", io.EOF, "b", io.EOF].However, that makes the common case harder to read, IMO. Right now, you’ll see a lot of code like this:
vs. if it’s append:
In the latter, the data you’re adding is kind of buried in the middle of the line, whereas the former it’s easier to see because it’s always right at the end of the line.
The current (prepend) order of arguments also follows the convention of
context.WithValue, the variouslogger.WithValuemethods, and evenappend, that take the original thing first and the added thing second. I worry that if we make iterrors.With(add, orig)that many people will make the mistake of writing it the opposite way, just from being used to the many other functions that follow the other order.The underlying issue here is that we need a way to provide context to errors. People have come with different solutions:
All of them try to answer the same question “What is this error about?” for some code that needs to act depending on the answer.
The stdlib should provide a solution, preferably one that fits most of the uses cases with the least added complexity.
Fwiw, in cockroachdb/errors we are also fine with semantics that attach an ancillary error to compare true on errors.Is but can’t be retrieved with errors.As
– Verstuurd vanaf mijn Android apparaat met K-9 Mail. Excuseer mijn beknoptheid.
It’s not ambiguous if we specify the traversal is depth first.
You could ask the same question of &myErr{cause:&myErr{cause:someErr}}
There are two candidates for errors.As and yet everyone is fine with the semantics.
Ian Lance Taylor @.***> schreef op 2 mei 2022 06:25:09 CEST:
I prefer the idea of adding support for multiple %w verbs in fmt.Errorf to adding errors.With. Allowing multiple %w verbs feels more like lifting a restriction on the current API than adding a new thing, and unlike errors.With, it’s not something you could do in a third party package. There are already plenty of third party multierror and error tree libraries, so the benefit from adding errors.With seems sort of low.
I’m not sure why people keep saying this. It’s not a tree. It’s a list, it’s just the underlying data structure looks like a tree, but it’s always traversed depth-first, so it has a defined order, so it’s a list.
I don’t understand that statement. Many people already write wrapping errors by hand, such that one error value can return true for more than one Is or As call. What’s different about this one?
Yeah, if it returned a structure that would unwrap to err1 and then err2, I suppose that would be ok. Though I really don’t like that error wrapping is currently hidden away in the fmt package.
Wrap might be a better name 🤷🏻♂️. I’m not beholden to the name (or the underlying implementation).
Because if we join two errors, then
errors.Isanderrors.Asbecome ambiguous. Instead of a single chain of errors wrapping other errors, we have a tree of errors.FWIW, the more I think about it, the more I think it would be better to have the sentinel error’s message prepended to the base error message. It would likely be surprising otherwise.
Something simple like
If you don’t want the sentinel to print out, it’s trivial to write a wrapper to squelch its output.
More discussion here, if anyone’s bored this weekend 😃 https://www.reddit.com/r/golang/comments/ue73jz/proposal_errorswitherr_other_error_error/
It’s still a chain with this implementation. Top’s chain gets added in front of all of the base error. That’s all.