I had already started playing with my
package for Go’s error handling when I read Russ Cox’s problem overview. It resonated:err2
In general Go programs have too much code checking errors and not enough code handling them ― Error Handling - Problem Overview
I ended up about the same solution as the Go2’s try-proposal. Not because it was the best, but because it was the only one.
Building error checking helpers is pretty tricky, especially without macros. The try-proposal gave me the trust that I’m on the right path―sort of. Even if the try-proposal were accepted, most of my package’s helper functions would be useful; and switching from
err2.Try()
to try()
would be easy.On the other hand, if the proposal would not be accepted, which happened, something else might be coming, which would still be in align with the package. For example, generics would help
err2
but not solve the actual problem:A: Implementing try requires the ability to return from the function enclosing the try call. Absent such a “super return” statement, try cannot be implemented in Go even if there were generic functions. ― try-proposal
You might ask, why bother? Why not accept how Go’s error handling is currently working? The short answer: because so little was missing.
We like to talk that Go has explicit error propagation. However, I agree with Swift authors:
I’ve heard people talk about explicit vs. implicit propagation. I’m not going to use those terms, because they’re not helpful: there are at least three different things about error-handling that can be more or less explicit, and some of the other dimensions are equally important. ― Swift Error Handling Rationale and Proposal
We should speak about automatic versus manual error propagation. But does it mean that automatic is always implicit and the other way around? Do we think that garbage collection is a bad thing, or RAII is a bad thing? Of course not, they are both automatic and pretty implicit, but very important.
Garbage collection is perhaps the best example of simplicity hiding complexity. - Rob Pike
The most common use case is to propagate an error to an end-user. Go offers a smart way to produce annotated error messages in the K&D style:
genesis: crashed: no parachute: G-switch failed: bad relay orientation
The only thing you have to do is to annotate errors with
fmt.Errorf
during the recursive error returns.dir, err := ioutil.TempDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v", err)
}
Indeed, that is an elegant way to build decent error messages. But soon you start to wonder that I don’t want to repeat myself. I need a helper package. Then you remember:
Use the language to simplify your error handling ― Errors are Values - Rob Pike
Below is the
CopyFile
function from the Error Handling ― Problem Overview. It shows how we usually handle errors in Go.func CopyFile(src, dst string) error {
r, err := os.Open(src) // .0: # entry
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err) // .1: if.then
}
defer r.Close() // .2: if.done
w, err := os.Create(dst)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err) // .4: if.then
}
if _, err := io.Copy(w, r); err != nil {. // .5: if.done
w.Close() // .7: if.then
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if err := w.Close(); err != nil { // .8: if.done
os.Remove(dst) // .10: if.then
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
return nil // .11: if.done
}
The control-flow graph (CFG) below is generated from
CopyFile
with Go’s analysis package and rendered as DOT presentation. I have made a helper tool to print CFG of the named function from the given package(s).Below is the
CopyFile2
function implemented with help of err2
package:func CopyFile2(src, dst string) (err error) {
defer err2.Returnf(&err, "copy %s %s", src, dst)
r := err2.File.Try(os.Open(src))
defer r.Close()
w := err2.File.Try(os.Create(dst))
defer err2.Handle(&err, func() {
os.Remove(dst)
})
defer w.Close()
err2.Try(io.Copy(w, r))
return nil
}
This is the control-flow graph of the
CopyFile2
.Is this fair? I think it is.
That is how I see it my self when I’m reading these two code blocks. Things get nastier when some of the if-statements are an essential part of the algorithm, i.e. a happy path. It’s hard to find these decision points when you are skimming over code.
Error checks are essential, but because of their nature, they turn to noise without automatic propagation.
Automatic error propagation boosts the incremental hacking cycle, which will help us build better software faster.
The most important learning has been that this was all we needed.
We don’t use any error value wrapping packages. End-users get informative error messages, and programmers get stack traces for their mistakes, and debugging is more straightforward. That is achieved by following these three rules:
io.EOF
is not an error. We must prevent errors with good API design e.g. CreateWalletIfNotExist(name string)
.Every function that uses
err2
for error-checking must have at least one error handler. Quite often, there isn’t any cleanup to do. Then the error annotation is enough. The err2
package has err2.Annotate()
and err2.Returnf()
for that.Following code-block illustrates both error annotation and using panics for programming errors.
func (cs *ConnSign) verifySignature(pipe *sec.Pipe) (c *Connection, err error) {
defer err2.Annotate("verify signature", &err)
if pipe != nil && pipe.Out.VerKey() != cs.SignVerKey {
s := "programming error, key mismatch"
glog.Error(s)
panic(s)
} else if pipe == nil { // we need a tmp DID for a tmp Pipe
did := ssi.NewDID("", cs.SignVerKey)
pipe = &sec.Pipe{Out: did}
}
...
We have used the
err2
package almost two years for now with few projects, and this is what we have learned: