Elegance of Go's error handling

Every so often, go’s error handling pops up in various forums and everyone seems to have an opinion about it. Some say they should be more like throwable exceptions, others prefer sum types like rust’s Result<T, E>. While I’ve gone with the sum type approach in typescript, I still like the way go handles errors.

That said, figuring out how to really handle errors can take some time (with or without sum types/exceptions). In this post, I’ll be walking through one approach on handling errors in go’s http.Handler.

The classical example

The error values can be frustrating if you expect them to scale out “just like that” without repeating themselves. Usual example goes something like this:

func copyfile(src, dst) error {
	fsrc, err := os.Open(src)
	if err != nil {
		return err
	}
	defer fsrc.Close()

	fdst, err := os.Open(src)
	if err != nil {
		return err
	}
	defer fdst.Close()

	err := io.Copy(fdst, fsrc)

	return err
}

Thats not so bad, and I’m quite sure that most of you have seen example like that before. But let’s take a look at similar situation that might occur in a http.Hanlder:

func handleThing(w http.ResponseWriter, r *http.Request) {
	// Our path is something like /thing/3
	id, err := idFromPath(r.URL.Path)
	if err != nil {
		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusNotFound)
		return
	}

	thing, err := store.GetThingByID(id)
	if err != nil {
		// The error might be sql.NoRows, or it might be something else.
		if store.IsNotFoundErr(err) {
			http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
			return
		}
		log.Printf("Failed to get a thing: %v", err)
		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		return
	}

	acc := AccountFromRequest(r)
	if acc == nil {
		// No account attached to the request's session -> permission denied.
		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
		return
	}

	has, err := thing.HasPermissionToView(acc)
	if err != nil {
		// For some reason, we failed to check permissions. Better log it.
		log.Printf("Failed to check permissions: %v", err)
		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		return
	}

	if !has {
		// Permission denied.
		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
		return
	}

	// All good, send data to the client.
	respond(w, r, decodeThing(thing))
}

Theres some functions that you’ll have to imagine is defined somewhere, but the functionality is this:

  • Extract ID from URL
  • Use that ID to get the thing from database
  • Check if the client has permission to view the thing
  • Give the thing to the client

This functionality probably repeats itself for other resource types, so it gets repetitive quite fast. Imagine doing the same for resources like foo, bar, account and so on! Same functionality can be written in Django like this:

def handle_thing(request):
    id = id_or_bad_request(request)
    thing = thing_or_404(id)

    account = account_or_forbidden(request)

    if not thing.has_permission(account):
        raise Forbidden()

    return JsonResponse(...)

Now that’s quite a lot simpler, thanks to throwable errors and how they can be used to disrupt the code’s flow. id_or_bad_request, thing_or_404 and account_or_forbidden all throw an error that someone catches somewhere higher and does the appropriate thing, like respond with correct status code and log any errors.

Trying to simplify it

Keeping that python code in mind, let’s think what we could do in our go code to get it a bit more terse:

  • When an error occurs, we just want to “throw” it somewhere. It usually is a client error, but not always. Perhaps someone else can figure that out?
  • If a non client error occurs, it needs to be logged somewhere
  • Someone else should be able figure out the http.Error calls

Golang’s error handling and Go talks a bit about error handling in your http handlers and gives the following example:

func viewRecord(w http.ResponseWriter, r *http.Request) error {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return err
    }
    return viewTemplate.Execute(w, record)
}

// NOTE: the following is my adapted version from the example's ServeHTTP to a
// middleware/wrapper

type HandlerE = func(w http.ResponseWriter, r *http.Request) error

func WithError(h HandlerE) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if err := h(w, r); err != nil {
			http.Error(w, err.Error(), 500)
		}
	}
}

That already solves one of our problems, which is the http.Error call. But sometimes we don’t want to expose detailed errors to the client, so I would replace the actual message with just a generic internal server error message. Also, logging the reasons for internal server errors is important, so that you can figure out what went wrong.

Improving the WithError wrapper

We want to return an error from our actual http.Handler, but somehow instruct the WithError wrapper function to respond correctly to the client when it gets an error, and on some errors log the errors. Something like this:

func WithError(h HandlerE) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if err := h(w, r); err != nil {

			if is404err(err) {
				http.Error(w, "not found", 404)
				return
			}

			if isBadRequest(err) {
				http.Error(w, "bad request", 400)
				return
			}

			// Some other special cases...
			// ...

			log.Printf("Something went wrong: %v", err)

			http.Error(w, "Internal server error", 500)
		}
	}
}

Hmm, those “other special” cases might come and go and might get quite specific for some handlers. Also, we’d still need to write those is404err and isBadRequest handlers and whatever will follow. We can do much better with an interface:

type ErrorResponder interface {
    // RespondError writes an error message to w. If it doesn't know what to
    // respond, it returns false.
	RespondError(w http.ResponseWriter, r *http.Request) bool
}

With this interface we can do quite powerful things. Our WithError turns into this:


func WithError(h HandlerE) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if err := h(w, r); err != nil {
			if er, ok := err.(ErrorResponder); ok {
				if er.RespondError(w, r) {
					return
				}
			}

			log.Printf("Something went wrong: %v", err)

			http.Error(w, "Internal server error", 500)
		}
	}
}

Notice how our special cases just disappeared? They are now just another implementation(s) of ErrorResponder. This is what would our Not found and Bad request errors now looks like:


// BadRequest error responds with bad request status code, and optionally with
// a json body.
type BadRequestError struct {
	err  error
	body interface{}
}

func BadRequest(err error) *BadRequestError {
	return &BadRequestError{err: err}
}

func BadRequestWithBody(body interface{}) *BadRequestError {
	return &BadRequestError{body: body}
}

func (e *BadRequestError) RespondError(w http.ResponseWriter, r *http.Request) bool {
	if e.body == nil {
		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
	} else {
		w.WriteHeader(http.StatusBadRequest)

		w.Header().Set("Content-Type", "application/json")
		err := json.NewEncoder(w).Encode(e.body)

		if err != nil {
			log.Printf("Failed to encode a response: %v", err)
		}
	}

	return true
}

func (e *BadRequestError) Error() string {
	return e.err.Error()
}

// Maybe404Error responds with not found status code, if its supplied error
// is sql.ErrNoRows.
type Maybe404Error struct {
	err error
}

func Maybe404(err error) *Maybe404Error {
	return &Maybe404Error{err: err}
}

func (e *Maybe404Error) Error() string {
	return fmt.Sprintf("Maybe404: %v", e.err.Error())
}

func (e *Maybe404Error) Is404() bool {
	return errors.Is(e.err, sql.ErrNoRows)
}

func (e *Maybe404Error) RespondError(w http.ResponseWriter, r *http.Request) bool {
	if !e.Is404() {
		return false
	}

	http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
	return true
}

You could easily write more ErrorResponders for permission denied errors and much more.

Where we’re at

With ErrorResponder and WithError, we can reduce our earlier handleThing handler into this:

func handleThing(w http.ResponseWriter, r *http.Request) error {
	// Our path is something like /thing/3
	id, err := idFromPath(r.URL.Path)
	if err != nil {
		// Literally bad request. We could use BadRequestWithBody to
		// respond with a fancy information for the client.
		return BadRequest(err)
	}

	thing, err := store.GetThingByID(id)
	if err != nil {
		// Likely a not found issue, but something else might have gone wrong.
		// Maybe404Error handles both cases.
		return Maybe404(err)
	}

	acc := AccountFromRequest(r)
	if acc == nil {
		// No account attached to the request. Client needs to authenticate.
		return AuthenticationRequired()
	}

	has, err := thing.HasPermissionToView(acc)
	if err != nil {
		// Something actually went wrong. Error will be logged and 500 message
		// sent to the client.
		return err
	}

	if !has {
		// Client doesn't have permission to view this resource.
		return PermissionDenied()
	}

	// All good, send data to the client.
	respond(w, r, decodeThing(thing))
}

func main() {
	...
	mux.Handle("/thing/", WithError(handleThing))
	...
}

Thats a lot better! I’ll leave it as an exercise to the reader to combine the auth and permission checking. Another exercise is to do a bit better logging than just “Something went wrong: error” in the WithError function. Perhaps log the path and requester, or use trace ids?

With all this, we can now:

  • “throw” errors somewhere
  • Someone else is figuring out the http.Error calls
  • Non client errors are logged

Final thoughts

Sometimes I’m amazed just by how simple (yet powerful) go’s error type is. Other times I’m banging my head against the wall because I can’t figure out how to use that simplicity. The solution presented in this blog post is really simple, but it took me (for me) an embarrassingly long time to “figure out”.

I’ve written quite a lot of rust too, and I like to think that I would come up with a similar solution quite a bit faster in rust than in go. But that rust solution would probably be suboptimal because it would be “premature”. I’ve written a lot of that “log error, call http.Error and return” error handling and lived with the pain of that repetition long enough to see all the various use cases. With rust, I probably would have rushed into a generalization too soon, and got stuck with bad generalization.

Lastly, I’ve noticed that less experienced developers don’t necessarily have the courage to go out and write an WithError wrapper that would be used across all the project’s code base. They expect that the tools they use provide such general functionality, like django does. Or maybe they “know” go, but don’t know go (and its philosophy)? Dunno, maybe I’m just reflecting my own experiences here.