Golang ― More Error Handling with Less Checking

Written by lainio | Published 2021/01/14
Tech Story Tags: golang | go | error-handling | error | automation | control-flow | programming | incremental-hacking

TLDRvia the TL;DR App

I had already started playing with my 
err2
 package for Go’s error handling when I read Russ Cox’s problem overview. It resonated:
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.

Error Propagation

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. Simplicity is Complicated - 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

Manual Error Propagation

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).

Automatic Error Propagation

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.

Learnings

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:
  1. Use error values only for factual errors, i.e. function cannot do what it advertised. For example, 
    io.EOF
     is not an error
    . We must prevent errors with good API design e.g. 
    CreateWalletIfNotExist(name string)
    .
  2. Use panics for programming errors, aka assertions and preconditions. We disagree with the defensive programming strategy behind Go’s decision not to have assertions. Offensive programming leads faster to correct software. Some parts of the Go’s standard library use offensive strategy as well.
  3. Always annotate errors. That is pleasant work to do with automatic error propagation without code-smells of manual propagation.
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}
	}
	...

Conclusion

We have used the 
err2
 package almost two years for now with few projects, and this is what we have learned:
  1. Deferred functions are natural places for error handlers in Go. (Declarative control structure)
  2. It’s proven to help us incrementally add better error handling and better error messages.
  3. In general, our code is panic-safe which is vital for long-running goroutines.
  4. If non-local control flows were needed, we would have a platform ready for that.
  5. Understand better what pattern recognition means for programmers: our brain tries to find decision points from the code based on its layout and syntax-highlighting―as pros we use all the help we can get.

Written by lainio | Programmer
Published by HackerNoon on 2021/01/14