Exploring Error Handling Patterns in Go

When you're learning another language, there can be periods of frustration where you're having trouble expressing an idea that would have been easier in a more familiar language. It's natural to wonder why the new language was designed that way, and it's easy to get fooled into thinking that—if you're having trouble expressing an idea in a language—it must be the fault of the language's authors. This line of reasoning can lead you to using a language in ways that are not idiomatic for that language.

One such topic that has challenged my own conceptions is how errors are handled in Go. To recap:

  • An error in Go is any type implementing the error interface with an Error() string method.
  • Functions return errors just like any other value. Multiple return values distinguish errors from normal return values.
  • Errors are handled by checking the value(s) returned from a function and propagated to higher layers of abstraction through simple returns (perhaps adding details to the error message).

For example, consider a function that resolves a host address and starts listening for TCP connections. There are two things that can go wrong, so there are two error checks:

func Listen(host string, port uint16) (net.Listener, error) {
  addr, addrErr := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port))
  if addrErr != nil {
    return nil, fmt.Errorf("Listen: %s", addrErr)
  }

  listener, listenError := net.ListenTCP("tcp", addr)
  if listenError != nil {
    return nil, fmt.Errorf("Listen: %s", listenError)
  }

  return listener, nil
}

This topic has its own entry on the Go FAQ, and the community has offered a wide range of opinions on the matter. Depending upon your past experiences, you may be inclined to think:

While those are useful features in other languages, they are not likely to be implemented anytime soon in Go. Instead, let's take a look at a few ways that we can write idiomatic Go code today, using the features that are already available.

A longer example

There may not be much to change in the prior example to make it shorter or simpler, but writing an if statement after each function call can start to feel like it's getting out of hand:

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
  requestText, err := readCRLFLine(reader) //string, err Response
  if err != nil {
    //No input, or it doesn't end in CRLF
    return nil, err
  }

  requestLine, err := parseRequestLine(requestText) //RequestLine, err Response
  if err != nil {
    //Not a well-formed HTTP request line with {method, target, version}
    return nil, err
  }

  if request := router.routeRequest(requestLine); request != nil {
    //Well-formed, executable Request to a known route
    return request, nil
  }

  //Valid request, but no route to handle it
  return nil, requestLine.NotImplemented()
}

There are a few things left to be desired in this method:

  1. The total number of lines in this method suggests that some helper functions could be extracted.
  2. Returning a successful value from the middle of two error cases makes it hard to read.
  3. It's not clear if the second err is a new variable at a new address or a re-assignment of the first one. It's not entirely consistent with the single-variable form of :=, which forbids re-assignment.

First option: Keep it and move on

While this version of parse does bug me, using an if guard after each possible error is the idiomatic way of handling errors in Go. We will explore some other ways to refactor this code, but keep in mind that this code does what it needs to do without abusing features of the language. There is an advantage to Go's sometimes Spartan philosophy: When there is one, clear way to do something, you can accept it and move on (even if you're not thrilled with the standard).

Take Go's official code format and its stance on forbidding unused variables and imports, for example. I may disagree with the way some code is formatted and think that there are reasonable times to allow unused variables, but tools like goimports make it easy to follow standards, and the compiler doesn't leave you with much of a choice. The time that would otherwise be spent deliberating alternatives can now be re-focused on other code that might be more important.

Back to the code in question—we can explore different structures that may clarify its control flow, but the lack of general-purpose, higher-order functions in Go limits our options.

Non-idiomatic options

You may be familiar with other approaches to control flow that are used in other languages, and it can be tempting to try to apply your favorite technique to Go. Let us briefly consider a few tempting options that are likely to raise eyebrows in the Go community, in all but the rarest of circumstances.

Defer, Panic, Recover

The first is called Defer, Panic, and Recover, where panic and recover are used sort of like throw and catch in other languages. There are a few points worth noting here:

The Go authors tend to make distinctions between expected branches in control flow (valid and invalid input, for example) and large-scale events that threaten the process as a whole. If you are to remain in the good graces of the Go community, you should make an effort to reserve panic for the latter.

Higher-order functions and wrapper types

Go authors like Rob Pike say over and over again to just write a "for" loop, but it's hard to resist thinking about the example problem as a series of transformations:

bufio.Reader -> string -> RequestLine -> Request 

Shouldn't we be able to write a map function for this?

Go is a statically typed language without generics, so your options are to declare types and functions with the types specific to your domain, or abandon type safety altogether.

Imagine what it would look like if you tried to write map:

// Sure, we can declare a few commonly used variations...
func mapIntToInt(value int, func(int) int) int { ... }
func mapStringToInt(value string, func(string) int) int { ... }

// ...but how does this help?
type any interface{}
func mapFn(value any, mapper func(any) any) any {
  return mapper(value)
}

There are options that abandon type safety, like this Go Promise library. However, take a close look at how the example code carefully sidesteps the issue of type safety by using the output value in a function that accepts any type (fmt.Println does ultimately use reflection to type its argument).

var p = promise.New(...)
p.Then(func(data interface{}) {
  fmt.Println("The result is:", data)
})

Making a wrapper like Scala's Either type doesn't really solve the problem either, because it would need to have type-safe functions to transform happy- and sad-path values. It would be nice to be able to write the example function a bit more like this:

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
  request, response := newStringOrResponse(readCRLFLine(reader)).
    Map(parseRequestLine).
    Map(router.routeRequest)

  if response != nil {
    return nil, response
  } else if request == nil {
    //Technically, this doesn't work because we now lack the intermediate value
    return nil, requested.NotImplemented()
  } else {
    return request, nil
  }
}

But look how much single-use code you would have to write to support that:

func newStringOrResponse(data string, err Response) *StringOrResponse {
  return &StringOrResponse{data: data, err: err}
}

type StringOrResponse struct {
  data string
  err Response
}

type ParseRequestLine func(text string) (*RequestLine, Response)
func (either *StringOrResponse) Map(parse ParseRequestLine) *RequestLineOrResponse {
  if either.err != nil {
    return &RequestLineOrResponse{data: nil, err: either.err}
  }

  requestLine, err := parse(either.data)
  if err != nil {
    return &RequestLineOrResponse{data: nil, err: either.err}
  }

  return &RequestLineOrResponse{data: requestLine, err: nil}
}

type RequestLineOrResponse struct {
  data *RequestLine
  err Response
}

type RouteRequest func(requested *RequestLine) Request
func (either *RequestLineOrResponse) Map(route RouteRequest) (Request, Response) {
  if either.err != nil {
    return nil, either.err
  }

  return route(either.data), nil
}

So the various forms of writing higher-order functions wind up being non-idiomatic, impractical, or both. Go simply is not a functional language.

Back to basics

Now that we have seen how functional styles are not likely to be helpful, let's remind ourselves:

The key lesson, however, is that errors are values and the full power of the Go programming language is available for processing them.

The other bit of good news is that you can't unknowingly ignore a returned error, like you can with an unchecked exception. The compiler will force you at a minimum to declare the error as _, and tools like errcheck do a good job of keeping you honest.

Function grouping

Looking back at the example code, there are two definite errors (input not ending in CRLF and not-well-formed HTTP request lines), one clearly successful response, and one default response.

Why don't we just group those cases together?

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
  requested, err := readRequestLine(reader)
  if err != nil {
    //No input, not ending in CRLF, or not a well-formed request
    return nil, err
  }

  return router.requestOr501(requested)
}

func readRequestLine(reader *bufio.Reader) (*RequestLine, Response) {
  requestLineText, err := readCRLFLine(reader)
  if err == nil {
    return parseRequestLine(requestLineText)
  } else {
    return nil, err
  }
}

func (router HttpRouter) requestOr501(line *RequestLine) (Request, Response) {
  if request := router.routeRequest(line); request != nil {
    //Well-formed, executable Request to a known route
    return request, nil
  }

  //Valid request, but no route to handle it
  return nil, line.NotImplemented()
}

Here we get the benefit of a smaller parse at the cost of a couple of extra functions. You get to decide whether you prefer more functions, or larger functions.

Happy and Sad paths in parallel

One could also refactor the existing functions to handle happy and sad paths at the same time.

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
  return router.route(parseRequestLine(readCRLFLine(reader)))
}

//Same as before
func readCRLFLine(reader *bufio.Reader) (string, Response) { ... }

func parseRequestLine(text string, prevErr Response) (*RequestLine, Response) {
  if prevErr != nil {
    //New
    return nil, prevErr
  }

  fields := strings.Split(text, " ")
  if len(fields) != 3 {
    return nil, &clienterror.BadRequest{
      DisplayText: "incorrectly formatted or missing request-line",
    }
  }

  return &RequestLine{
    Method: fields[0],
    Target: fields[1],
  }, nil
}

func (router HttpRouter) route(line *RequestLine, prevErr Response) (Request, Response) {
  if prevErr != nil {
    //New
    return nil, prevErr
  }

  for _, route := range router.routes {
    request := route.Route(line)
    if request != nil {
      //Valid request to a known route
      return request, nil
    }
  }

  //Valid request, but unknown route
  return nil, &servererror.NotImplemented{Method: line.Method}
}

Instead of four if guards in the original example, we're down to three without adding any more functions. The trade-off is having to read the top-level parse inside-out.

Closure for errors

You can also create a closure over the first error. The example code from the article starts like so:

_, err = fd.Write(p0[a:b])
if err != nil {
  return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
  return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
  return err
}

The author creates a func that applies the next step as long as no error has been encountered yet.

var err error
write := func(buf []byte) {
  if err != nil {
    return
  }
  _, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
if err != nil {
  return err
}

This approach works well when you can pass each step in your workflow to the closure, which suggests that each step should operate on the same types. In some cases, this can work great.

In the example in this blog, you would have to create a struct to close over the error and write separate applyParseText / applyParseRequest / applyRoute functions for each step in the workflow, which is probably more trouble than it's worth.

Conclusion

Although Go's design choices in error handling may seem foreign at first, it's clear from the various blogs and talks its authors have given that these choices were not arbitrary. Personally, I try to remember that the burden of inexperience lies with me—not with the Go authors—and that I can learn to think of an old problem in new ways.

When I started writing this article, I thought it might be possible to apply experiences from other, more functional languages to make my Go code simpler. This experience has been a good reminder to me of what Go authors have been emphasizing all along: sometimes it's helpful to write a few functions specific to your own purposes and move on.

Kyle Krull, Software Crafter

Kyle Krull is a Software Crafter at 8th Light, Chicago, with a passion for automated testing and recording music.

Interested in 8th Light's services? Let's talk.

Contact Us
+ + +