Skip to main content
Preslav Rachev
  1. My Writings/

I Don’t Like Go’s Default HTTP Handlers

·4 mins
Explicit > Implicit

Disclaimer: This is my personal opinion. You don’t have to agree or disagree with it. I’d be happy if you learned something from my rant in the end.

All Go programmers learn early on what the shape of the standard HTTP handler function looks like:

func hello(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello, World!"))
}

It is simple and easy to remember. At the same time, it is low-level enough not to limit or obscure the developer in any possible way—typical Go.

The Problem #

Let’s build a slightly more complex example where the flow has to go through a couple of potentially error-prone operations before returning to the client.

func aMoreAdvancedHandler(w http.ResponseWriter, r *http.Request) {
	resOne, err := potentiallyDangerousOpOne()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	resTwo, err := potentiallyDangerousOpTwo(resOne)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}

	resThree, err := potentiallyDangerousOpThree(resTwo)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Write([]byte("Success: " + resThree))
}

And here comes the big problem in my view. You aren’t actually returning anything. Regardless of the outcome, you simply write it to the response writer. This can lead to unnoticeable and difficult to trace bugs like in the example above:

resTwo, err := potentiallyDangerousOpTwo(resOne)
if err != nil {
	http.Error(w, err.Error(), http.StatusInternalServerError)
	// Did we forget to return here?
}

While we acknowledged the error, we didn’t return it immediately after it. By definition, we aren’t forced to, so the compiler wouldn’t complain either. It might lead to a situation in which, despite the error, we still tell the client that everything went alright—either that or some other form of inconsistent behavior.

The missing empty return is sometimes referred to as a “naked” return. It appears in all C- Style langu­ages, so it is not a problem with Go’s syntax, but it is hard for me to justify using naked returns. Whenever I can, I will make the junction always return a value, or if it is high enough in the stack (e. g. main), cause it to exit the entire application instead.

Unfortunately, this isn’t how everyone else thinks, which is why I am raising this point. Numerous HTTP frameworks for Go exist, but one of the most popular ones - Gin seems to be following the same style.

func (h *Handlers) getAlbumGin(c gin.Context) {
	id, ok := c.Params.Get("id")

	if !ok {
		c.Error(fmt.Errorf("get album: missing id"))
		return
	}

	album, err := h.db.FetchAlbum(id)
	if err != nil {
		c.Error(fmt.Errorf("fetch album with id %s: %w", id, err))
		// Same thing here. Oh, man 🤦‍♂️
	}

	cover, err := h.db.FetchAlbumCover(id)
	if err != nil {
		c.Error(fmt.Errorf("fetch cover for album with id %s: %w", id, err))
		return
	}

	c.JSON(http.StatusOK, gin.H{"album": album, "cover": cover})
}

Do you like my writing? Don’t forget to follow me on Twitter.

The Solution #

For comparison, here is how my favorite one - Echo, approaches the same subject.

func (h *Handlers) getAlbum(c echo.Context) error {
	// NOTE the explicit err return value here ⬆

	id := c.QueryParam("id")
	if id == "" {
		return fmt.Errorf("get album: missing id")
	}

	album, err := h.db.FetchAlbum(id)
	if err != nil {
		return fmt.Errorf("fetch album with id %s: %w", id, err)
	}

	cover, err := h.db.FetchAlbumCover(id)
	if err != nil {
		return fmt.Errorf("fetch cover for album with id %s: %w", id, err)
	}

	return c.JSON(http.StatusOK, echo.Map{"album": album, "cover": cover})
}

By enforcing the explicit return of an error, Echo minimizes the chance of accidentally forgetting to return it. Unless developers explicitly ignore the error, they will have to do something with it; otherwise, the compiler won’t compile because of an unused variable. The easiest thing is returning it, which will signal Echo to write the appropriate message and status code to the HTTP response writer.

Can we do the same without a framework? Absolutely, it is pretty easy to do, but as you can see in the Echo example, it will require a slight change of the standard HTTP handler function. It will require us to abstract the writer away and instead return values the way a regular function would.

type MyResponseWriter struct {
	w http.ResponseWriter
}

func (rw *MyResponseWriter) WriteString(str string) error {
	_, err := rw.w.Write([]byte(str))
	return err
}

// You can potentiall extend MyResponseWriter with indefinite helper methods


type MyHandlerFunc func(w *MyResponseWriter, r *http.Request) error

// This is how a new handler would look like
func someHandler(w *MyResponseWriter, r *http.Request) error {
	res, err := potentiallyDangerousOp()
	if err != nil {
		return fmt.Errorf("someHandler: %w", err)
	}

	return w.WriteString(res)
}

// And this is what we will use to bridge the gap with the existing http package
func MakeHTTPHandler(fn MyHandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rw := MyResponseWriter{w: w}
		err := fn(&rw, r)
		if err != nil {
			// Note that this is the only place where we actually use
			// the original writer's error reporting capabilities
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	}
}
http.Handler and Error Handling in Go · questionable services
Technical writings about computing infrastructure, HTTP security.

Have something to say? Join the discussion: