-
Notifications
You must be signed in to change notification settings - Fork 17.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
proposal: errors: simplified error inspection #32405
Comments
Are the semantics of E() as you propose them the same as pkg/errors.Cause()? If so, why not reuse the method? If not, how do they differ? |
@carlmjohnson No, the semantics are different, though not dissimilar. That Cause function returns nil if there's an error with a Cause method that returns nil, which means that Also, the associated conventions are different. In pkg/errors, the Furthermore, the term This was a good question BTW. I should mention this difference in the "comparison" section. |
@rogpeppe, you might want to drop a note about this issue on golang-dev. |
This is true, but I don't typically see this type of error inspection on the hot path.
If you log an error, call
I don't understand why this is confusing. Could you clarify? Here is my use case, why I appreciate the current 1.13 implementation, and where your proposal would fall short: My applications work with databases a lot. This interaction can have errors at multiple places:
I also tend to use helper functions that may combine information from the SQL text with the SQL error to extract and highlight the line of the error. In this case the SQL driver that handles the error condition won't (and shouldn't) have access to the SQL text. This helper function will wrap the an error with additional query context information. Furthermore, almost all SQL errors (of all types) are used for business logic and thus sometimes errors have distinct business logic ramifications that may need to be tested for. So given the above, what would be the "cause", or what would the "definitive" error? It depends on what I'm doing. I sometimes want to look at the "cause" (maybe invalid SQL), but other times I need to see what business logic error and take some action based on that (maybe failed to fetch order). The current proposal satisfies all these needs with very little code change. This will be much easier to explain to most new Go developers and is similar to what they may be used to from Java or C# with multiple |
I think that may depend significantly on the application. Some error kinds (for example "not found" errors) can be very frequent.
If I want to understand why my program is behaving in a specific way when handling errors, I can't always stop it at runtime and ask "Is" or "As" questions of the error value. Often I'll need to create a log message that provides some reasonable post-mortem view of the error. With the current proposal, that's not easy to do, because the entire chain of errors contributes to that decision-making process, including dynamic method dispatch to
"Is" feels like a kind of equality operator. This relationship sounds like A=B and A=C but B ≠ C, which is confusing to me at least.
It seems to me like you're describing an error domain here. It would be up to you (or someone else, of course) to define the conventions for the way that errors work in that domain. This is not unrelated to the approach I've suggested for OS-related errors in the proposal above. For example, you could define an interface that encapsulates information about an SQL error:
Then you can implement specific types to represent specific business logic errors, each of which could implement SQLError to make available the underlying SQL error that caused the business logic error. Although, to be honest, if you've diagnosed a business logic error, you probably don't care about the underlying SQL error any more, so it probably wouldn't be that useful to include it. In your code, you could do:
The point I'm trying to make with this proposal is that there doesn't have to be a one-size-fits-all approach to error inspection. The only common aspect to all error handling schemes is that we need to be able to add metadata without affecting the error value that we use for making decisions, which is what I've proposed. I suggest that it's actually quite rare to encounter a situation where we are really mixing and matching a bunch of different kinds of errors from different layers, which is what the current proposal facilitates. Instead, this proposal favours an encapsulation approach where each layer makes its own decision as to what conventions to support. Those conventions can and should be mentioned in the API documentation so the caller knows what kinds of errors to expect and can check accordingly. It's my intuition that this approach has nicer compositional properties than the current proposal and will scale better to large code bases, making large-scale refactoring less error-prone due to less unexpected interactions between errors in different layers. Not to mention that's it's much simpler. :) |
This is not confusing to me. I understand your design rational I believe. It is compelling.
I suspect you may underestimate this, or the cost of refactoring other layers / shims to make this work over time. |
A is a B. A is a C. B is not a C. “is” is a different relationship than “is a”. |
The core idea about just adding metadata without actually changing the actionable error is captured pretty good with this proposal. However, from a tooling perspective, the implementation doesn't look complete. From // When writing code that makes a decision based on an error, the
// E-value should always be used in preference to the error value
// itself, because that allows functions to add metadata to the error,
// such as extra message annotations or source location information,
// without obscuring the actual error. With this in mind, if I have an intermediate value that wraps the raw error with a higher level error, that in turn is also wrapped again, how would I check for that middle error? I can assert the top-level error with the error value, and the bottom-most with E-value, but to assert on mid-level (intermediate) error there seems to be no way around but to write custom logic for it. While I agree that current proposal is too complicated (I fully agree that debugging the errors with dynamic dispatch does sound great - it's a solid argument against it) - this one seems to be lacking parts of an API to make life easier - like build-in support for walking around the error chain. Although, it's unclear if it's actually a requirement - maybe it's a good idea to force people to write their own code for that. |
That may be so. However, I think in practice the cases where this level of detail are needed are comparatively uncommon, and while we shouldn't prevent those domains from addressing their issues, I wonder whether it makes sense for the whole Go ecosystem to pay in a more complex API. Domains where layered inspection is needed will need to define shared abstractions for errors in any case, even with proposal #29934. Some attractive aspects of #32405 here are a smaller, conceptually simpler API, and better separation of concerns: Cases where a chain of errors is really needed can still have their way, but my experience of using the I'd argue that it's preferable to make APIs that really want the complexity to pay the cost for it—and I think the cost wouldn't be that high, relative to an API big enough to care. The design proposal advocated chaining in a fairly general way ("more than one error might be worth examining") without motivating examples. |
If an error is important enough to look at, it is an E-value (i.e. it has no chained E-value below it). If some other error wraps it in the way you're talking about, then that's an E-value too. The E property is not about wrapping - it's about adding metadata. By metadata, I mean extra data that programs should not inspect to determine behaviour, such as stack frame information or the error string. If an error When you say that an error A "wraps" another value B, I would say that you have one error, A(B), not two independent errors, (A, B). So in your case, if you really have three significant layers of wrapping, you'll have A(B(C)), not (A, B, C), and if you want to look for the middle one, error A will have to make its wrapped error B available explicitly (that's what the OSError proposal and the SQLError suggestions above both do). I hope that makes things a little bit clearer than mud :) |
You may be right. I think it's worth taking that risk. FWIW we are already in that situation, and I don't see that things are working too badly. I'm much more concerned about creeping layer interconnection and increasing fragility in large-scale programs than I am about this aspect tbh. YMMV of course. |
So, that means I'd have to make my intermediate error an E-value (with it's As an example, given I have the following: type IntermediateError struct { NonE: error, /* ... */ }
func (e IntermediateError) Error() string { /* ... */ }
func someFn() error {
// ...
if err != nil {
if myCond(errors.E(err)) {
return &IntermediateError{NonE: err}
}
return err
}
} Then, somewhere at the much higher level code I can do: switch v := errors.E(err).(type) {
case *IntermediateError:
// Handle high-level error logic, with an option to handle the lower-level error
// ... logic ...
// Here I can also access low-level error if needed
if v.NonE.os.(os.OSError).OsError() == oserror.ErrPermission {
// logic for permission error that is also an *IntermediateError.
// only a particular kind of permission error is wrapped as *IntermediateError.
} else {
// other case...
}
case os.OSError:
if v.OSError() == oserror.ErrPermission {
// logic for permission error that is NOT an *IntermediateError
// (possibly coming from a different, unexpected source).
}
} This code snippet is an example of the case I have in mind. With this approach, even though it's much more verbose, it kind of communicates the complexity better that how I'd write it with two lines of code with the current proposal. This feels a lot less magical, I actually like it. |
Putting my philosophy degree to work FWIW: Is has two relevant senses here: identity and predication. If I say, “Bruce Wayne is Batman” that’s an identity statement. They just are two names for the same fictional person. The transitive, symmetric, reflexive properties apply. But if I say, “Batman is a comic book character” that’s predication. The identity properties don’t apply. It appears that errors.Is means identity but actually it’s just predication. I can see why that’s confusing. |
I dropped out of my philosophy program, but FWIW:
It’s worse than that—it’s kind of both. The docs:
|
In this case that is less a philosophical statement, however, than a straightforward issue of well-foundedness: The recursive definition via the implicit |
@MOZGIII Although note that if the code that created
You could always switch the logic too, if you only cares about
|
If I'm reading this correctly, the main difference between this and the original proposal, beyond the simplicity aspect, is that this proposal seems to force programs to coordinate a lot more on what I would call an "error hierarchy". That is, if you have more than one type of error that you want to be able to expose, every call site needs to be aware of the "E-value hierarchy". For example, to use the For small programs I doubt this is a problem. For large programs though, keeping this correct seems really hard. If you have a few different error types to check for it seems hard to guarantee that the same hierarchy is followed through all different call paths of a program. That would lead to programs having to check for the same error type in multiple different ways. In the example above you would have a check for Lastly it seems to me to introduce the undesirable property that introducing a new type of error that can be checked for will have a large and cascading effect on programs. All callers that check for some other error type, that is now possibly being wrapped in the new type being introduced, will need to change. In large programs I suspect this would be a large amount of code churn for an activity that we presumably want to make easy and even encourage (introducing new error types). Not to mention that even finding all the callers that may somehow receive an error of the new type will be quite difficult in complex programs. I don't know what the right solution is. I really like the simplicity of this proposal but the above stand out to me as fairly large downsides for large programs. Whether the simplicity is worth that trade-off I cannot say. |
@eandre for large programs (with long error chain) i would say that's the opposite, adding a new error type in the chain should be more explicit than magical. If not, all error would have been wrapped by default which was rejected by the proposal. |
Sorry @flibustenet for not being clear. I'm not saying the act of adding a new error type should be magical. But if a new error type is added that wants the original error to be available (the new error type is "unwrappable" in the words of the original proposal), it seems to me that would lead to a large amount of code changes all over the program in this proposal. |
@eandre in my example above, the whole point is for the higher level code to know about both With the current proposal, the code will be smaller, but it will be definitely more tricky to find a bug in, if a bug were somewhere in the chain structure. With this proposal it becomes more straightforward, as unwrapping the chain is done manually - similarly to how it was done before the updated error handing proposal. Also, in my opinion, error types are parts of the public API of a package, and adding new error types, therefore, should not be magical by default anywhere as much as possible. The whole point of explicitly handling error flows (aka lack of exceptions, of the main features of Go) is to force users to think about error conditions in a direct way, without obscuring the details. |
@MOZGIII I understood that your example was about that. I gave a different example, where a body of code already exists, and various places check for various types of existing errors. In such a use case, introducing a new error type ( |
If you add a new meaningful error that wraps the underlying errors for you to be able to alter the control flow based on it (as in my example) - then changing a lot of code to handle it is probably what you're doing it for in the first place. This is equivalent to introducing a completely new, independent error, that you need to handle. If the error is wrapped in way that only adds additional metadata (like that is was passed through package X), then the wrapper type implements the Additional bonus, is for structured logging purposes, wrapped error can be cleanly split into a part that affects control-flow and metadata, allowing one to implement a logger in a way that unifies representation of the control flow errors and having wrapper errors metadata in a separate, optional fields. |
Speaking of metadata though, in the structure logging system I'd probably want to have stack traces for both control-flow affecting and wrapper errors. |
I see adding a new error type rather as adding another dimension on the error that you can inspect (at least when the error is "unwrappable"). So any given error may at the same time be a permission error as it is an RPC error. Introducing a new error type |
Thanks @rogpeppe for this well-considered proposal and thanks everyone for the discussion here. Like @MichaelTJones, I am truly impressed by the quality of this discussion. Roger's main argument as I understand it that there should be a single answer to "what error is this really?", and the concept of the E-value is meant to capture "what the single answer is". Ultimately I think the decision about whether this is a viable approach reduces to whether it is in fact the case that there is a single answer to "what error is this really?". The Error Values problem overview we wrote last summer gives this example:
Roger's proposal seems to take the position that one should not be asking that variety of questions. There should be one answer: either errors.E says this error is a WriteError, or it says it is an RPCError, or it says it is an OpError, or a PathError, or a EPERM. There is no flexibility to be more than one. Even if we just simplify to a PathError containing EPERM, you want to be able to type assert for PathError and also check for EPERM. Those questions can't both be answered directly by one error result: either errors.E(err) == EPERM or errors.E(err).(*os.PathError) succeeds, but not both. For the specific case of PathError and EPERM, Roger's proposal suggests adding a second kind of unwrapping, the OSError method plus os.Error helper function, that fetches the underlying OS error. In general, then, it seems that there would need to be an unwrap method convention and helper function for every axis of error kind. This is certainly more explicit, and Roger and others have said they appreciate how explicit everything becomes. In contrast, the concept of a chain that you walk and test to answer questions intentionally avoids that complexity. Ultimately there is a tradeoff: do you prefer the complex code and explicitness or do you prefer the simpler code and implicitness? In general, for the language spec and standard library, we are focused on establishing shared conventions that the ecosystem benefits from agreeing on, usually because it promotes interoperability. The Is/As/Unwrap proposal suggests agreeing on those three methods, which seem to have enough flexibility to accommodate a wide variety of behaviors that might be appropriate in different packages, including returning errors that can be unwrapped and those that cannot. This E proposal suggests a narrower method set, with the effect of narrowing the behaviors that can be implemented in it and also making it more difficult to evolve errors as packages evolve. For example, you can't start returning a different "stopping point" error from the E method, because existing direct tests of that error will start failing. In contrast, if you needed to update the specific error type with Is/As/Unwrap, you could make the new type answer the Is/As questions (by implementing those methods) to continue to look like the old error too. That kind of evolvability seems important. Relying on language-level == and .(T) is attractive from considerations of parsimony but it cuts off this evolvability. It's one of the key reasons people aren't happy with the current status quo. And the error type you want for == and for .(T) are usually different, so one function - errors.E(err) - can't gracefully provide the left hand side for both. I don't want this reply to sound like cutting off the conversation. Again, I have been impressed by the quality of this conversation and I hope it continues. I simply wanted to point out that the generality and comparatively higher complexity of Is/As/Unwrap is very much intentional, and try to explain why. |
I think one key idea behind Roger's proposal has perhaps been understated. That is, that given a tower of error values—e.g., the WriteError→RPCError→net.OpError→os.PathError→syscall.Errno example—there are many questions one could ask about the types of these values, but that it is not clear that their answers are independent of each other. In other words: When does it make sense to ask "does this chain contain The example of the "not found" clash from #32405 (comment) illustrates this: If I am a particular program, it is likely not safe for me to assume that any Roger's proposal does not solve that problem, but does I think better separate the concern of metadata wrapping from that of error-type judgement, which the Is/As/Unwrap proposal folds together. Is/As/Unwrap is very general, but in my (small) trials of Whether or not this is the right solution, I think it's important not to lose that kernel of insight, that error chains are not broadly context-free. |
Is/As/Unwrap methods implement a pattern that can be applied to other values, not just error types. When I thought about it, I asked myself: do I want to have that pattern available for any types in the language? Probably, in general, yes, especially Is/As part, but in a very limited and well-thought way. In some cases it would be meaningful and useful, and it would be great to have something language-wide for that. However, I currently don’t see a way to prevent people from abusing this pattern. I’m afraid it becomes a wide-spread alternative to interfaces - it has the same property that it allows generalization, but I think we already have enough of that in the language (with interfaces). Perhaps we should consider Is/As a general, not-just-errors thing? Going back to just errors - the same logic applies. Errors are just values after all, and even though those values are often used in a particular context - there’s a potential a way to abuse the feature. That said, I really like helper functions like I feel like this proposal is exactly what I need - as it solves one of the major pain points of wrapping the errors with custom metadata - which typically complicates the actual control flow. |
Btw, what does As do if there are multiple |
Per https://godoc.org/golang.org/x/xerrors#As: // As finds the first error in err's chain that matches the type to which target points […] |
Finding the first error in the chain might not work for every case. If we are to end up with As in the language - we probably need some support for building a more customized chain inspection logic. For example, if there's a chain of errors |
@rsc
Yes, that is my main argument but I don't think that the "single answer" restriction is as restrictive as you're making it out to be. I believe Go's interface type assertions provide a neat way of providing evolvability without needing to have universal chain-based error inspection.
I think that this is a great example actually. Let's say I write some code that's calling the database-writing function and checking the returned error:
What do I mean when I write this code? Am I checking whether I don't have permissions to write to the database? The answer is potentially: all of these things! Every level shown there could potentially wrap an error for which Now let's say that I'm really just interested in checking whether the client doesn't have permission to write to the database. This is made worse by the fact that errors are inherently sporadic. Let's say I'm reviewing the above code. I observe that there are appropriate unit tests and that we've got 100% code coverage. I approve the review. Then in 6 months' time, someone has this issue with permissions on their Maybe the other code path doesn't even exist at the time of review. Later on, someone added new code in some dependency and broke production in some unlikely (but potentially important) edge case.
This is true when you're talking about concrete types and values, but ISTM that this is where Go's interface types can work nicely. In this case, both questions can indeed by answered directly by one error result:
You seem to be asserting that being explicit results in more complex code. In fact, I found that when I changed the standard library to be explicit instead of using ISTM that the worry with the explicitness here is not that the code becomes more complex (it doesn't, it seems), but that the error checking code is less general and that the error-returning code will not be able to evolve to fit future requirements.
I agree that with my proposal, when you use I believe there is a trade-off here: I don't think we can provide the freedom to insert arbitrary intermediate types in the chain without also running into the kind of ambiguity issues that I've tried to illustrate earlier in this post.
With some care, it is possible to expose errors which are evolvable. If we assert errors for behaviour, not type, then when we add an intermediate type, we can make it implement the expected interface. For example, as I mentioned earlier, I'm interested to hear about real-world cases of this class of evolvability problem. I think that just as with any other part of an API, it will usually be possible to evolve in a backwardly compatible way, by using different entry point names, providing opt-in flags, etc.
Thanks again for your thoughts. They have made me more aware that there is a trade-off to made here, even though on balance, I still think that it would be better to go with the simpler approach I've outlined in this proposal. Note that if we go for the E-value approach, it is still compatible with the Unwrap and friends. I don't think the converse is true though, which is why I think it would be worth at least pausing for a little longer consideration rather than publishing the errors package as-is in Go 1.13. |
I think the problem of "what does this permission error mean" is more a result of (1) careless usage of unwrappable errors, and (2) over-use of ambiguous or generic errors. In the database example, we should not repurpose errors that signify OS-level permission denied to mean application level "user doesn't have access to perform that database operation". Similarly, a database should not carelessly expose unwrappable errors. It seems to me that these guidelines must be followed for both proposals equally. |
@rogpeppe, thanks for the reply. Two small points. - In the 5-level example, I agree that there is potential for layering confusion in the example from the errors problem overview. I think that one is a bit extreme, and in some ways I regret it, but it's the one I had at hand. In general I think we need to establish a kind of best practice that errors returned by a particular package should probably not expose internal problems to Is/As/Unwrap. In a real system I would probably draw an "error opacity" line between 1+2 and 3+4+5, so that the top-level error would not be a "permission denied" in that sense. The smaller examples, things like a plain PathError wrapping EPERM, are sufficient in my mind to exclude any kind of "function returning an error to use in == or .(T)", including errors.E. More on that in a separate followup comment. - I've been involved in a handful of efforts over the years to try to define 'common error spaces' in different languages for programmatic inspection. In each case, I felt like we did the best we could in that context at that time, but none of them has ever reached anything I was completely happy with, including this one. I think Is/As/Unwrap is an important, needed improvement over the current status quo in Go, and I think it's the best we can do in this context at this time, but it's certainly not perfect. I don't know what perfect is here, and I freely admit that. 1/3 |
@rogpeppe's initial post says:
I agree about the scannability, but we could easily rewrite the current code to look more like Roger's:
What this highlights is that the main difference is not that one approach inherently produces nicer implementation methods but that ErrTemporary in particular is strange, and that it is probably a mistake to try to use Is to check for error properties as opposed to error kinds, or at least that we should be more careful about distinguishing the two. I think maybe we should drop ErrTemporary from the Go 1.13 changes. I was going to suggest ErrTimeout as well, but mapping EAGAIN/EWOULDBLOCK/ETIMEDOUT/EBUSY to ErrTimeout all seem pretty direct. That is, timeout seems like a specific kind of error, something you could reasonably return from a function as a description of what went wrong; in contrast, temporary is only an adjective for certain errors that does not stand by itself. If we did remove ErrTemporary, then the current code would simplify further to:
which I think is pretty much at the same level of simplicity as Roger's initial version. I've filed #32463 to discuss removing ErrTemporary. 2/3 |
As I mentioned in 1/3 above, I think PathError containing EPERM is instructive to consider as far as whether the Is/As/Unwrap generality/complexity is truly needed or we could use the less general, less complex errors.E instead. Package io is meant to define its own concepts, independent of os. An operating system is one way to do I/O, but there is lots of alternate I/O in Go as well (for example, all the various functions that return an io.Reader or io.Writer not backed by an *os.File). In particular, package io defines io.EOF (and io.ErrUnexpectedEOF). Package os defines
The methods on *os.File return *os.PathErrors, for example
and be confident that the error will include the file name. The exception to this general rule is io.EOF, because wrapping it in a PathError would made it much more difficult to write what today is:
If we'd had errors.Is at the start, we could have let the *os.File methods return a *os.PathError even for EOF, and What about with errors.E? Because some code may want to get at the actual *os.PathError and inspect the path, errors.E must return the *os.PathError, not io.EOF. This (errors.E) proposal suggests adding a separate OSError method and os.Error helper to get at the underlying error, so that That seems like a layering violation: io.EOF is an I/O error, not an OS error. What if the code is, say, io.Copy and is written to take an io.Reader. It makes no sense to be calling os.Error - there's nothing from package os in sight. The obvious solution is to duplicate @rogpeppe's suggested changes for package os into package io, defining:
Then
to the more complex:
And then os.PathError has to implement both OSError and IOError:
And sometimes the underlying error is a syscall.Errno (a type implementing error), which can be important. Today you can write
and then
and then people could write And so on. The result is a very explicit, very clear statement of relationships between errors. You could even imagine making each of the specific error interfaces returning themselves, as in But if this approach has a fundamental flaw, it's the impossibility of generality. Consider a helper to unpack a zip file to disk:
The UnzipFileError allows the caller to programatically determine which file went wrong, in case that is necessary, but it also exposes the underlying error. The caller may also want to check for specific errors, like os.ErrPermission or syscall.ENOSPC. With Is/As/Unwrap, the author of ziputil only has to add:
and then whatever worked for errors.Is and errors.As on the original error will work on the wrapped error. In particular,
This is more work but still is maybe OK and defensible, since presumably that code was calling os.Create, so it "knows" about os. (It's unfortunate to need to import syscall just to add SyscallError.) But now suppose we want to generalize our zip helper to arbitrary file storage implementations:
Now there is not even a direct use of package os. It's hard for me to accept UnzipFileError being required to implement OSError and SyscallError when it is written in general terms without reference to either of those packages. And what if I have a remote file system implementation of FS that returns a RemoteFSError? If I want to check for a specific one of those errors, it's definitely not OK to expect ziputil to import that remote file system implementation to implement the RemoteFSError method. As a caller I guess I just have to know about UnzipFileError and test it myself:
That is, in this case, the errors.E approach leaves the developer with no choice but to continue checking for and reaching inside specific wrapper types. Admittedly, this is true only for the inner error that the callee did not anticipate, but knowing that is problematic on its own: In contrast, with Is/As/Unwrap, there is a simple rule. Provided ziputil implements the single, trivial Unwrap method, then errors.Is works as well for remotefs.ErrProblem as it does for os.ErrPermission:
I don't believe this is an isolated or unlikely example. I think this kind of thing will come up over and over again, in basically any code that tries to parameterize over an interface with a method that returns an error. In that setting, the caller knows more about what kinds of errors might come back than the callee does. (And even if the callee does know the full set, having to add all those methods may trigger imports that would otherwise be unnecessary.) These examples are all about errors.Is, so they could be handled in part by redefining errors.E to return the canonical "Is" value (only "in part" because of legacy concerns like syscall.EPERM vs os.ErrPermission being different errors but also partly the same), or in full by defining that errors should implement an Is method that takes care of chaning, instead of Unwrap. That breaks down for type assertions, as I noted yesterday, so then you need to define that an error wrapper implements both chaining Is and chaining As, or you define (as we have) that an error wrapper defines the single chaining Unwrap used by both errors.Is and errors.As. 3/3 |
This! When doing It would help a lot if all stdlib errors could be traced to their source API. The generic sentinel errors seem mysterious. |
Hmm, with Is, io.Copy could start wrapping its errors in io.ErrReader or io.ErrWriter while preserving the underlying error. Not Go 1 compatible, but you could do it as a module version or something. |
@networkimprov, a bit off topic, but if you have io.Copy(w, r), w is a conn created by package net, and r is an *os.File, then you should be able to distinguish any error today by checking for *net.OpError vs *os.PathError already. The only non-wrapped possibility for w.Write or r.Read should be io.EOF from the read side, and io.Copy disposes of that. And if I'm wrong about that, then of course you could also write io.Writer and io.Reader wrappers that annotate the errors from the underlying calls however you want. No changes to the error model needed. |
Thanks @rsc. Hm, I misspoke above. What had puzzled me was the variety of error types from net.Conn.Read() for a TLS connection. I have this...
I wish they were all net.Error or net.OpError, especially in context of |
I agree with the observation that the new errors API seems under-tested, and I also share @rsc’s experience of trying several times to come up with a universal error API for introspection and struggling to make it perfect for all use cases. Almost all the conversation so far has dealt with error handling, but I think error reporting is also important. I slightly prefer I assert this because when debugging an error you want to know, what was the program doing and what went wrong. Both proposals let you log what went wrong, but only by looking at the intermediate errors can you tell what the program was trying to do (that said, if go errors had stacktraces I think those would be better still, at significantly less code complexity to library authors). When handling a concrete error I think the existing manual type casting is better than both errors.E() and errors.Is/As. In our codebase we have a bunch of helper functions like The main use-case for Is/As that I see is so that libraries can start (or stop) wrapping errors without breaking calling code. I don’t know that this is a flexibility we should push for, as changing the concrete return type will still break any code that is doing error handling with explicit type casts. In short, from my experience, it’s useful to have a common way to chain errors to assist with manual debugging and logging, and building further error tooling. I don’t see a need for a common way to pull specific errors out of the chain (explicit destructuring seems more robust). My concrete proposal would be to keep |
[aside: I'm still working on my response, @rsc :) ]
It's important to mention that neither |
I think formatting the error is one way to provide extra context, but I tend to use tools like https://bugsnag.com to provide a much richer view on errors that happen in production. When looking at https://github.com/golang/proposal/blob/master/design/go2draft-error-printing.md it is strange to me that the we’re requiring that the author provide both Unwrap and also Format() returning the last error. The justification given is that library authors may decide not to allow Unwrap but still return the error from Format(), I disagree that this is desirable. In my experience it’s extremely difficult to predict what errors callers will care about, because it is environment dependent; and any information that I might use as a programmer to debug the problem should also be available at runtime to decide how to handle it. An error that when printed out shows a nested errors, but when inspected at runtime does not, looks like a bug. If the error formatting proposal used Unwrap, then library authors would have one fewer interface to implement in the common case that they want to wrap an error and add extra information about the programs intent. If the error formatting API didn’t need to return an error, then it could be simplified. But improvements to that proposal might be out of scope for this discussion. |
Even leaving aside the orthogonal question of rich formatting for errors, the currently-active API proposal for errors moves away from the principle that "errors are (just) values". With Is/As/Unwrap, errors are not "just values" any longer, but are complex chains of values with sophisticated behaviour. I personally don't care that much about the philosophical implications of the difference, but I do care about the performance implications: Empirically, a lot of code seems to depend on the assumption—which held for most of Go's existence so far—that checking for specific errors on a hot path (e.g., the recurring "not found" example) is a cheap equality comparison or maybe a (single) type assertion. I have certainly written a lot of Go code with that assumption. The Is/As/Unwrap API actively encourages nesting—and in cases where you are trying to debug an unexpected error or log details, the cost of that API may not matter since you're probably already in some bad state and are about to unwind the stack a bunch. But in the cases where nothing bad has happened, and all you want is to quickly discriminate "not found" from "permission denied", the difference is potentially substantial. This aspect seems not to have been discussed much—and I've found it a bit tricky to get good benchmarks for the difference, in part because the new API is still too new to be widely used. I like about the E-value proposal that it better separates the concern of error discrimination from the concerns of error decoration and error introspection. This E-value proposal doesn't address all those concerns, but I think it deserves more attention because by contrast the Is/As/Unwrap proposal doesn't really allow separating them. I think it's important to be clear that this proposal isn't about choosing between the current proposal and "not solving those other concerns". However, I haven't seen much attention paid to the cost of the API in balance to the problems it addresses. |
This issue arrived after the Is/As/Unwrap proposal (#29934) was accepted. It gave us significant food for thought but ultimately did not lead the authors of that proposal to back it out. Given that Is/As/Unwrap have been accepted, it doesn't make sense to accept this one as well - they are both trying to solve the same problem. I posted more extensive comments above: three different comments starting at #32405 (comment). |
It seems like we should decline this proposal, given that Is/As/Unwrap was accepted. |
This is not bad. Now we get to actually use the language with As/Is/Unwrap applied proposal to the fullest, and if we find issues with it we can always "rebase" this proposal in the form of "fixes" to the As/Is/Unwrap. Good thing is we might not have issues that were predicted in this discussion in practice. It might be more challenging technically though - now when there’s Is/As/Unwrap in, but not impossible. |
Hopefully @rogpeppe will be able to provide a response to your points in that timeframe. |
Marked this last week as likely decline w/ call for last comments (#32405 (comment)). |
Issue #29934 proposes a way of inspecting errors by traversing a linked chain of errors.
A significant part of the implementation of this is about to land in Go 1.13. In this document, I outline some concerns with the current design and propose a simpler alternative design.
Analysis of the current proposal
I have a few concerns about the current design.
internal/reflectlite
package.As
implies an allocation.As
andIs
methods means that you can't in general ask what an error is; you can only ask whether it looks like some other error, which feels like it will make it hard to add definitive error information to log messages.Is
method dispatch makes it easy to create unintuitive relationships between errors. For example,context.ErrDeadlineExceeded
"is" bothos.ErrTimeout
andos.ErrTemporary
, butos.ErrTimeout
"isn't"os.ErrTemporary
. Anet.OpError
that wraps a timeout error "is"os.ErrTimeout
but "isn't"os.ErrTemporary
. This seems like a recipe for confusion to me.Although I've been concerned for a while, I did not speak up until now because I had no alternative suggestion that was simple enough for me to be happy with.
Background
I believe that the all the complexity of the current proposal and implementation stems from one design choice: the decision to expose all errors in the chain to inspection.
If inspection only checks a single underlying error rather than a chain, the need for Is and As goes away (you can use
==
and.()
respectively), and with them, the need for the two unnamedIs
andAs
interface types. Inspecting an error becomes O(1) instead of O(wrapDepth).The proposal provides the following justification:
It seems to me that this justification rests on shaky ground: if you know you have a PathError, then you are in a position to ask whether that PathError also contains a permission error, so this test could be written as:
This seems like it might be clumsy in practice, but there are ways of working around that (see below). My point is that any given error type is still free to expose underlying errors even though there is only one underlying error (or "Cause").
Current state
As of 2019-06-02, the
As
andIs
primitives have been merged into the Go master branch, along with some implementations of theIs
method so that OS-related errors can be compared usingerrors.Is
. Error printing and stack frame support were merged earlier in the cycle but those changes have recently been reverted.The xerrors package implements more of the proposed API, but is still in experimental mode.
Proposed changes to the errors package
I propose that
As
andIs
be removed, and the following API be added to theerrors
package:I've used the name
E
rather thanCause
to emphasise the fact that we're getting the actual underlying error; the error being passed around may include more information about the error, but the E value is the only important thing for error inspection.E
also reflects theT
name in the testing package.Although the
E
method looks superficially similar toUnwrap
, it's not the same, because error wrappers don't need to preserve the error chain - they can just keep the most recent E-value of the error that's being wrapped. This means that error inspection is usually O(1). The reason for the loop inside theE
function is to keep error implementations honest, to avoid confusion and to ensure idempotency:errors.E(err)
will always be the same aserrors.E(errors.E(err))
.This playground example contains a working version of the above package.
Proposed changes to
os
errorsThe changes in this part of the proposal are orthogonal to those in the previous section. I have included this section to indicate an alternative to the current use of
Is
methods on types throughout the standard library, which are, it seems to me, a significant motivating factor behind the current design.The standard library has been retrofitted with implementations of the
Is
method to make some error types amenable to checking witherrors.Is
. Of the eleven implementation of theIs
method in the standard library, all but two are there to implement temporary/timeout errors, which already have an associated convention (an optional Timeout/Temporary method). This means that there are now at least two possible ways of checking for a temporary error condition: check for a Temporary method that returns true, or usingerrors.Is(err, os.ErrTemporary)
.The historic interface-based convention for temporary and timeout errors seems sufficient now and in the future. However, it would still be convenient to have an easy way to check wrapped errors against the errors defined as global variables in the
os
package.I propose that an new
OSError
interface with an associatedError
function be added to theos
package:Then, instead of a custom
Is
method, any error type that wishes to (syscall.Errno, for example) can provide anos
package error by implementing theOSError
method.This domain-specific check addresses this common case without complicating the whole error inspection API. It is not as general as the current proposal's error wrapping as it focuses only on the global variable errors in
os
and not on the wrapper types defined that package. For example, you cannot use this convention to check if a wrapped error is a*os.PathError
. However, in almost all cases, that's what you want. In the very small number of cases where you want to look for a specific wrapper type, you can still do so by manually unwrapping the specific error types via a type switch.Note that, as with historical Go, there will still be strong conventions about what kinds of errors may be returned from which functions. When we're inspecting errors, we are not doing so blind; we're doing so knowing that an error has come from a particular source, and thus what possible values or types it may have.
Discussion
As with the current proposal, it is important that this design does not break backward compatibility. All existing errors will be returned unwrapped from the standard library, so current error inspection code will continue to work.
This proposal is not as prescriptive as the current proposal: it proposes only a method for separating error metadata from the error value used for inspection. Other decisions as to how errors might be classified are left to convention. For example, an entry point could declare that returned errors conform to the current xerrors API.
The entire world of Go does not need to converge on a single error inspection convention; on the other hand we do need some way of wrapping an error with additional metadata without compromising the ability to inspect it. This proposal provides exactly that and no more.
As an experiment, I implemented this scheme in the standard library. The changes ended up with 330 lines less code (96 lines less production code), much of it simpler.
For example, it seems to me that this code:
is easier to understand than its current equivalent:
This proposal does not affect the error printing proposals, which are orthogonal and can be implemented alongside this.
Comparison with other error inspection schemes
This proposal deliberately leaves out almost all of the functionality provided by other schemes, focusing only on the ability to discard error metadata. The issue of how to inspect the E-value of an error is left to be defined by any given API.
In this way, the proposed scheme is orthogonal to other error frameworks, and thus compatible with them.
For example, although it does not directly support Unwrap-based chain inspection or error hierarchies, there is nothing stopping any given API from documenting that errors returned from that API support those kinds of error inspections, just as existing APIs document that returned errors may be specific types or values. When bridging APIs with different error conventions, it should in most cases be possible to write an adaptor from one convention to another.
The E-value definition is quite similar to the
Cause
definition in the errgo package, but it has one important difference - the E-value is always its own E-value, unlikeCause
, which can return an error which has another Cause. This eliminates one significant source of confusion, making a strict separation between error metadata and errors intended for inspection. The error metadata can naturally still hold the linked chain of errors, including stack frame and presentation information, but this is kept separate from the E-value - it should not be considered for error inspection purposes.Summary
This proposal outlines a much simpler scheme that focuses entirely on separating error metadata from the error inspection value, leaving everything else to API convention.
I believe there are significant issues with the current design, and I would prefer that the new errors functionality not make it into Go 1.13, to give us more time to consider our future options.
The text was updated successfully, but these errors were encountered: