paint-brush
Golang ― More Error Handling with Less Checkingby@lainio
4,364 reads
4,364 reads

Golang ― More Error Handling with Less Checking

by Harri LainioJanuary 14th, 2021
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Error checks are essential but turn to noise without automatic propagation. Fixing Go's error-handling.

Company Mentioned

Mention Thumbnail

Coins Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Golang ― More Error Handling with Less Checking
Harri Lainio HackerNoon profile picture

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