kpat.io
June 9, 2019

Go Error Propagation

Error handling is a vital part of all programming languages. There’s any number of things that can go wrong if you interact with the outside world. A file can be missing or corrupt, an endpoint temporarily unreachable, a database server down, etc. Things can go wrong everywhere. Now in order to keep the systems we work on maintainable and user friendly, we have to handle these errors appropriately.

Different languages offer different mechanisms for handling errors. Most modern programming languages, for example, include a feature called exceptions as a convenient way to propagate errors through multiple layers. This feature is missing in Go - and that’s on purpose.

In the following sections we’ll explore some common methods for avoiding error handling. I like call these anti-patterns or “don’ts”

Method 1: Ignore

The first anti-pattern in error handling - and this is not limited to go - is ignoring them alltogether. Here is a piece of code that makes excessive use of the underscore (_). The underscore, also known as the blank identifier, is used when calling functions with multiple return values to discard one of these values.

Let’s have a look at the example

// file: main.go

package main

import (
    "io/ioutil"
    "log"
    "net/http"
)

func main() {
    cont := doWork("google.com")

    log.Println(cont)
}

func doWork(host string) string {
    client := &http.Client{}

    req, _ := http.NewRequest("GET", host, nil)

    req.Header.Add("Accept", "application/json")
    req.AddCookie(&http.Cookie{Name: "ID", Value: "X"})

    resp, _ := client.Do(req)

    b, _ := ioutil.ReadAll(resp.Body)

    return string(b)
}

Looks good enough, right? So there is this doWork function that calls an API endpoint with a cookie, then reads the contents and returns them. Now running this will result in a SIGSEGV, a segmentation fault. Meaning, that one of the errors in the code is not nil and the corresponding unignored value therefore is.

We clearly don’t want code like this in our production systems. Let’s try something different.

Method 2: Delegate

Since go is annoying and doesn’t let us propagate the errors to the next layer, we can just try replicate that missing feature ourselves. What if we delegate the error to the calling function? We can just “offshore” the work to the poor fellow who will be interacting with our API.

Let’s have a peak what that might look like with our example

// file: main.go

// ...

func main() {
    cont, err := doWork("google.com")

    if err != nil {
        log.Panicln(err)
    }

    log.Println(cont)
}

func doWork(host string) (string, error) {
    client := &http.Client{}

    req, err := http.NewRequest("GET", host, nil)

    if err != nil {
        return "", err
    }

    req.Header.Add("Accept", "application/json")
    req.AddCookie(&http.Cookie{Name: "ID", Value: "X"})

    resp, err := client.Do(req)

    if err != nil {
        return "", err
    }

    b, err := ioutil.ReadAll(resp.Body)

    if err != nil {
        return "", err
    }

    return string(b), nil
}

We’ve added error delegation inbetween the business logic. The caller now gets a mysterious error message together with a trace that tells her where the error is originating.

$ go run main.go
panic: Get google.com: unsupported protocol scheme ""

This is marginally more useful than just crashing, I admit it, yet, not user friendly at all. So how can we improve that?

Improvements

Let’s say Lisa is interacting with our system. Now she’s getting an error from our API. What Lisa would like to know is

How can I adjust the input value to get rid of the error? What am I doing wrong?

This is a difficult question to answer. What we can easily answer is

What part of the system was I interacting with that produced the error?

Let’s glance at an example

// file: main.go

// ...

func doWork(host string) (string, error) {
    client := &http.Client{}

    req, err := http.NewRequest("GET", host, nil)

    if err != nil {
        return "", fmt.Errorf("Failed to create a new request: %v", err)
    }

    req.Header.Add("Accept", "application/json")
    req.AddCookie(&http.Cookie{Name: "ID", Value: "X"})

    resp, err := client.Do(req)

    if err != nil {
        return "", fmt.Errorf("Failed to execute the request with host \"%s\": %v", host, err)
    }

    b, err := ioutil.ReadAll(resp.Body)

    if err != nil {
        return "", fmt.Errorf("Failed to read from request body: %v", err)
    }

    return string(b), nil
}

And the corresponding output

$ go run main.go
panic: Failed to execute the request with host "google.com": Get google.com: unsupported protocol scheme ""

Now Lisa knows that we were trying to dispatch the request as the error happened. Together with the trace, I’d argue, she has a better chance of finding out what changes she has to make to fix her code.

Conclusion

There is no magic formula to getting error handling right. We just have consider the poor person interacting with our code. Make it a better experience for them.

Here are a few best practices around error handling. Some of them not covered by the scope of this article.

  • Try to give the programmer a hint of where the code is failing
  • Always include the trace for internal APIs
  • Hide error information from end users. This might expose a security flaw in your system
  • Try to fail softly. Some errors can be recovered from.
    • Few errors should render a service unfunctional
    • If, for example, the billing service is unresponsive, simply display a message instead of a internal server error