Web Controllers in Go
Go offers a lot of niceties out of the box with its expansive standard library but the http.Handler interface is lacking an error return type and therefore it's relatively easy to mess up error handling code. Let's see if we can do better.
Problem
Go already provides some abstractions around HTTP-based web servers namely http.Handler and, the often preferred variant, http.HandlerFunc:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}The ServeHTTP function doens't return any error value so it is clear, that all errors need to be handled within it. This usually leads to code using early returns which are just too easy to forget:
func myHandler(w http.ResponseWriter, r *http.Request) {
user, err := loadUser(r)
if err != nil {
http.Error(w, "not found", 404)
return // <--- Don't forget to return early.
}
// Do more stuff.
}At first it doesn't look like too big of a deal, at least in such a short example, but it can quickly get unwieldy if you have many failure paths.
So in an ideal world our new handler interface would therefore accept an additional error return value, enabling us to move all error handling logic up the call-chain so we potentially only ever need to maintain a single global error handler.
Our pseudo-code would then be more aligned with the typical Golang error handling flow:
type Handler interface {
ServeHTTP(ResponseWriter, *Request) error
}
func myHandler(w http.ResponseWriter, r *http.Request) error {
user, err := loadUser(r)
if err != nil {
return err
}
// Do more stuff.
}Reality Check
Unfortunately, this approach has one big downside - it is a lot of work...
We would need to create our own router implementation which can work with this new interface and we will be utterly incompatible with the rest of the Golang net/http ecosystem.
What we can do instead, is defining a transformation function taking our improved Handler interface and converting it back to the ol' reliable http.Handler.
type ErrorHandlerFunc func(e error, h Handler) http.HandlerFunc
We would then be able to process a specific error type like so:
func handlePathError(e error, h Handler) {
return func(w http.ResponseWriter, r *http.Request) {
err := &io.PathError{}
if errors.As(e, &err) {
http.Error(w, err.Msg, 404)
}
}
}Already not too bad but there are still two things missing:
- We most likely want to handle multiple error types
- Have some form of fallback error handling for unknown error types
Both points can be easily resolved by having an array of error handlers. This way, one can add more specific error handlers at will and, if none of the registered handlers matches, we can return some generic error response (e.g. the infamous 500 - Internal Server Error).
With these changes we end up with something akin to this:
type Handler func(http.ResponseWriter, *http.Request) error
type ErrorHandlerFunc func(e error, w http.ResponseWriter, r *http.Request) bool
type ErrorHandlers []ErrorHandlerFunc
func NewErrorHandlers() ErrorHandlers {
return []ErrorHandlerFunc{}
}
func (h *ErrorHandlers) AddHandler(f ErrorHandlerFunc) {
*h = append(*h, f)
}
func (h ErrorHandlers) Handle(handler Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := handler(w, r)
if err == nil {
return
}
for _, eh := range slices.Backward(h) {
if eh(err, w, r) {
return
}
}
log.Printf("unhandled error: %q", err.Error())
http.Error(w, "internal server error", 500)
}
}Usage
With the implementation out of the way, we can quickly look at an usage example before wrapping everything up:
func commentsHandler(w http.ResponseWriter, r *http.Request) error {
return loadComments()
}
func main() {
r := chi.NewRouter()
// Error handler setup
eh := web.NewErrorHandlers()
eh.AddHandler(func(e error, w http.ResponseWriter, r *http.Request) bool {
err := &AuthenticationErr{}
if errors.As(e, &err) {
http.Error(w, err.Msg, 401)
return true
}
return false
})
eh.AddHandler(func(e error, w http.ResponseWriter, r *http.Request) bool {
err := &io.PathError{}
if errors.As(e, &err) {
http.Error(w, err.Msg, 404)
return true
}
return false
})
// Register our HTTP handlers
r.Get("/comments", eh.Handle(commentsHandler))
http.Handle("/", r)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", 8080), nil))
}Although a bit verbose, we now have a reusable error-handling mechanism, allowing us to centralize our logic around dealing with failures.
I think this highlights pretty well what Go is often about. It lacks a lot of the high-level constructs some other frameworks include but gives us all the building blocks to make our own abstractions just the way we need them.