Some views on the Go 2 Error Inspection early draft proposal
The big news in Go lately has been the announcement of Go 2 Draft Designs for improved error handling, error values, and generics. The details of the proposed improvements to error values have been split into two parts, error printing and error inspection. Today I have some views on the error inspection draft (and you should also read the error values problem overview). Broadly, I like the proposal but I feel that it doesn't go far enough and the result of not doing so is going to be inconvenience and people reinventing the wheel repeatedly. So here are some rambling thoughts on the whole thing.
The error inspection draft proposes a standard interface for
unwrapping nested errors and then some standard functions to
conveniently do things with (wrapped) errors, errors.Is()
, which
checks to see if a specific error is somewhere in your wrapped
error, and errors.As()
, which tries to recover a specific type
of error from your wrapped error. A standard way of wrapping errors
and then checking through the error chain deals with my issue where
Go's net package has undocumented wrapped errors that are a pain
to deal with. In the new world, if I want
to see if an error is ultimately because of an EHOSTUNREACH
from
some system call, I would write something like:
if errors.Is(err, syscall.EHOSTUNREACH) { .... }
I like this code. It's short and it directly expresses what I'm
checking. Also, this automatically works with any level of error
nesting; I can be dealing with a *net.OpError
that has been wrapped
by my own code for better reporting of the details, and it all just
works.
As a side note, in a world with automatic error unwrapping via
errors.Is()
and errors.As()
, it becomes even more important to
actively and explicitly document what errors can be wrapped inside
your errors. People will actively want to know what error types
there's any point to look for, and making them dump your errors or
read your source code to find out is just annoying them. This
should lead to significantly more documentation on this in the Go
standard library.
In a world with errors.Is()
, it will be extremely annoying if
people needlessly erase errors by turning them into text strings
by running them through the current fmt.Errorf()
. As a result, I
believe strongly that there should be a standard interface for doing
the equivalent of fmt.Errorf()
for wrapping errors in a way that
is transparent to errors.Is()
. I am neutral on the overview's
open question of whether this should be a clever version of
fmt.Errorf()
, but certainly going that way would get fast adoption.
Given that errors.Is()
makes checking sentinel values one of the
easiest things you can do with errors (especially wrapped errors),
I expect to see a significant push towards more error sentinel
values. Today, I see a reasonable amount of code that doesn't have
constant sentinel values and always makes errors with fmt.Errorf()
;
in a future world with errors.Is()
, I expect there to be a lot
more sentinel error values, even if they're immediately wrapped up
in another layer of errors that provide those custom messages.
Now let's talk about where I feel this proposal doesn't go far
enough and as a result leaves people to reinvent wheels.
The first annoyance comes in if you have multiple sentinel errors
that you want to check against. For example, my check of EHOSTUNREACH
is actually incomplete; I should really check ENETUNREACH
and
ETIMEDOUT
as well, and perhaps some others. The current proposal
has no API for this, and as a result I expect that we'll see
people repeatedly writing their own version of a multi-error check.
I believe that there should be a standard API for this; the natural
interface is a varargs version of errors.Is()
, looking like this:
if errors.IsOneOf(err, syscall.EHOSTUNREACH, syscall.ENETUNREACH, syscall.ETIMEDOUT) { .... }
All of that is all very well and good for checking sentinel values, but what if you want to check that you have a network timeout error? In the current proposal, you wind up writing something like this:
if ne, ok := errors.As(*net.OpError)(err); ok && ne.Temporary() { .... }
We're going through all of this work simply to see if this is a
temporary network error, which is a simple 'is this a ...' query.
In a world where errors themselves could answer 'Is()' queries, it
would make sense for the net
package to have a net.TemporaryError
sentinel value and then to simply write this check as the much more
natural:
if errors.Is(err, net.TemporaryError) { .... }
I expect that pretty much every boolean 'is this a ....' method call on errors today would likely be more ergonomic if it was expressed as a sentinel value this way.
Under the hood, no net
error would ever actually be the
net.TemporaryError
sentinel, because they have to wrap other
errors. Instead they would have an Is()
check method that would
say that they matched net.TemporaryError
if .Temporary()
was
true.
This doesn't make errors.As()
unneeded, because there are things
you can want to recover from specific types of errors other than
boolean answers to questions. One case, drawn from my prior
experience, is knowing what system call
failed:
oe, ok := errors.As(*os.SyscallError)(err); if ok && oe.Syscall == "connect" { .... }
You would also want to use errors.As()
or something like it if
you had an interface of your own that you wanted to check against.
For example:
type temperror interface { Temporary() bool } if te, ok := errors.As(temperror)(err); ok && te.Temporary() { .... }
Here we're asking 'is it a temporary error in general' instead of
our earlier question of 'is it a temporary network error'. There
are a number of error classes even in the standard library that
have this interface (including syscall.Errno
itself), so you
might well want to match a wrapped error this way.
Now, this example pushes somewhat against my errors.Is(err,
net.TemporaryError)
example, because you can't generalize a sentinel
value the way you can a method call (which you can turn into an
interface, as here). If Go people specifically want to preserve this
general style of error method interface, then it probably makes sense
to avoid generalizing sentinels. Once general sentinels are available,
people may be tempted to implement 'is this a temporary error' and
the like purely through sentinels, which would make the error method
interface less and less useful.
Perhaps the Go standard library should document some common error
method patterns, such as .Temporary()
and .Timeout()
, partly
to encourage people to implement them more often. In a world with
errors.As()
, where it is much easier to check if something in a
wrapped stack of errors thinks the error is temporary or whatever,
we might see people doing this much more often than I think they
do today.
PS: Whatever exactly happens with Go error inspection, I think it's
going to cause a real change in how people both create and deal
with Go errors in future code. People are highly motivated to do
whatever is easiest, so we can expect them to steadily adopt
whatever approach is easiest to use with error inspection. This
holds true regardless of what you want them to do with the new
way. I also suspect that we aren't going to like some of the hacks
that people come up with to bend errors.Is()
and so on to what
they want to do.
Comments on this page:
|
|