Skip to content

Golang package for returning errors instead of handling them directly

License

Notifications You must be signed in to change notification settings

johnwarden/httperror

Repository files navigation

Package httperror is for writing HTTP handlers that return errors instead of handling them directly.

  • installation: go get github.com/johnwarden/httperror
  • godoc

Overview

func helloHandler(w http.ResponseWriter, r *http.Request) error {

	w.Header().Set("Content-Type", "text/plain")

	name, ok := r.URL.Query()["name"];
	if !ok {
		return httperror.New(http.StatusBadRequest, "missing 'name' parameter")
	}

	fmt.Fprintf(w, "Hello, %s\n", name[0])

	return nil;
}

func main() {

	h := httperror.HandlerFunc(helloHandler)

	http.Handle("/hello", h)

	http.ListenAndServe(":8080", nil)
}

Unlike a standard http.HandlerFunc, the helloHandler function above can return an error. Although there is no explicit error handling code in this example, if you run it and fetch http://localhost:8080/hello without a name URL parameter, an appropriate plain-text 400 Bad Request page will be served.

This is because helloHandler is converted into a httperror.HandlerFunc, which implements the standard http.Handler interface, but the 400 Bad Raquest error returned by helloHandler will be handled by a default error handler that serves an appropriate error page.

Advantages to Returning Errors over Handling Them Directly

  • more idiomatic Go
  • reduce risk of "naked returns" as described by Preslav Rachev's in I Don't Like Go's Default HTTP Handlers
  • middleware can inspect errors, extract status codes, add context, and appropriately log and handle errors

This package is built based on the philosophy that HTTP frameworks are not needed in Go: the net/http package, and the various router, middleware, and templating libraries that are compatible with it, are sufficient. However, the lack of an error return value in the signature of standard http handler functions is perhaps a small design flaw in the http package. This package addresses this without tying you to a framework: httperror.Handler is an http.Handler. You can apply standard http Handler middleware to it. And your handler functions look exactly as they would look if net/http had been designed differently.

Custom Error Handlers

Use WrapHandlerFunc to add a custom error handler.

func customErrorHandler(w http.ResponseWriter, e error) { 
	s := httperror.StatusCode(e)
	w.WriteHeader(s)
	// now serve an appropriate error response
}

h := httperror.WrapHandlerFunc(helloHandler, customErrorHandler)

Here is a more complete example.

Middleware

Returning errors from functions enable some new middleware patterns.

func myMiddleware(h httperror.Handler) httperror.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) error {
		err := h.Serve(w,r)
		if err != nil {
			// do something with the error!
		}
		// return nil if the error has been handled.
		return err
	}
}

Here is an example of custom middleware that logs errors.

PanicMiddleware and XPanicMiddleware are simple middleware functions that convert panics to errors. This ensures users are served an appropriate 500 error response on panic instead of an empty response. And it allows middleware to appropriately inspects, count, and log panics as they do other errors.

Extracting, Embedding, and Comparing HTTP Status Codes

// Pre-Defined Errors
e := httperror.NotFound

// Extracting Status
httperror.StatusCode(e) // 404

// Constructing Errors
e = httperror.New(http.StatusNotFound, "no such product ID")

// Comparing Errors
errors.Is(e, httperror.NotFound) // true

// Wrapping Errors
var ErrNoSuchProductID = fmt.Errorf("no such product ID")
e = httperror.Wrap(ErrNoSuchProductID, http.StatusNotFound)

// Comparing Wrapped Errors
errors.Is(e, ErrNoSuchProductID) // true
errors.Is(e, httperror.NotFound) // also true!

Public Error Messages

The default error handler, DefaultErrorHandler will not show the full error string to users, because these often contain stack traces or other implementation details that should not be exposed to the public.

But if the error value has an embedded public error message, the error handler will display this to the user. To embed a public error message, create an error using NewPublic or PublicErrorf instead of New or Errorf:

e := httperror.NewPublic(404, "Sorry, we can't find a product with this ID")

Public error messages are extracted by PublicMessage:

m := httperror.PublicMessage(e)

If your custom error type defines a PublicMessage() string method, then PublicMessage will call and return the value from that method.

Generic Handler and HandlerFunc Types

This package defines generic versions of httperror.Handler and httperror.HandlerFunc that accept a third parameter of any type. These are httperror.XHandler and httperror.XHandlerFunc.

The third parameter can contain parsed request parameters, authorized user IDs, and other information required by handlers. For example, the helloHandler function in the introductory example might be cleaner if it accepted its parameters as a struct.

type HelloParams struct {
	Name string
}

func helloHandler(w http.ResponseWriter, r *http.Request, ps HelloParams) error { 
	fmt.Fprintf(w, "Hello, %s\n", ps.Name)
	return nil
}

h = httperror.XHandlerFunc[HelloParams](helloHandler)

Use with Other Routers, Frameworks, and Middleware

Many routers and frameworks use a custom type for passing parsed request parameters or a request context. A generic httperror.XHandler can accept a third argument of any type, so you can write handlers that work with your preferred framework but that also return errors. For example:

var ginHandler httperror.XHandler[*gin.Context] = func(w http.ResponseWriter, r *http.Request, c *gin.Context) error { ... }
var httprouterHandler httperror.XHandler[httprouter.Params] = func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error  { ... }

See this example of using this package pattern with a github.com/julienschmidt/httprouter.

One advantages of writing functions this way, other than that they can return errors instead of handling them, is that you can apply generic middleware written for httperror.XHandlers, such as PanicMiddleware for converting panics to errors. In fact, this package makes it easy to apply middleware that was not written for any particular router or framework.

Applying Standard Middleware

You can apply middleware written for standard HTTP handlers to an httperror.Handler or an httperror.XHandler, because they both implement the http.Handler interface. See the standard middleware example.

However, the handler returned from a standard middleware wrapper will be an http.Handler, and will therefore not be able to return an error or accept additional parameters. Instead, use ApplyStandardMiddleware and XApplyStandardMiddleware, which return an httperror.Handler or an httperror.XHandler respectively. You can see an example of this in the httprouter example.

Similar Packages

github.com/caarlos0/httperr uses a very similar approach, for example the definition of: httperr.HandlerFunc and httperror.HandlerFunc are identical. I have this package to be mostly compatible with this httperr.

Example: Custom Error Handler

This example extends the basic example from the introduction by adding a custom error handler.

package httperror_test

import (
	"bytes"
	"errors"
	"fmt"
	"net/http"

	"github.com/johnwarden/httperror"
)

func Example_customErrorHandler() {
	// This is the same helloHandler as the introduction. Add a custom error handler.
	h := httperror.WrapHandlerFunc(helloHandler, customErrorHandler)

	_, o := testRequest(h, "/hello")
	fmt.Println(o)
	// Output: 400 Sorry, we couldn't parse your request: missing 'name' parameter
}

func helloHandler(w http.ResponseWriter, r *http.Request) error {
	w.Header().Set("Content-Type", "text/plain")

	name, ok := r.URL.Query()["name"]
	if !ok {
		return httperror.NewPublic(http.StatusBadRequest, "missing 'name' parameter")
	}

	fmt.Fprintf(w, "Hello, %s\n", name[0])

	return nil
}

func customErrorHandler(w http.ResponseWriter, err error) {

	s := httperror.StatusCode(err)
	w.WriteHeader(s)

	if errors.Is(err, httperror.BadRequest) {
		// Handle 400 Bad Request errors by showing a user-friendly message.

		var m bytes.Buffer
		m.Write([]byte("Sorry, we couldn't parse your request: "))
		m.Write([]byte(httperror.PublicMessage(err)))

		httperror.WriteResponse(w, httperror.StatusCode(err), m.Bytes())

	} else {
		// Else use the default error handler.
		httperror.DefaultErrorHandler(w, err)
	}
}

Example: Log Middleware

The following example extends the basic example from the introduction by adding custom logging middleware. Actual logging middleware would probably need to be much more complex to correctly capture information from the response such as the status code for successful requests.

package httperror_test

import (
	"fmt"
	"net/http"

	"github.com/johnwarden/httperror"
)


func Example_logMiddleware() {
	// This is the same helloHandler as the introduction
	h := httperror.HandlerFunc(helloHandler)

	// But add some custom middleware to handle and log errors.
	h = customLogMiddleware(h)

	_, o := testRequest(h, "/hello")
	fmt.Println(o)
	// Output: HTTP Handler returned error 400 Bad Request: missing 'name' parameter
	// 400 Sorry, we couldn't parse your request: missing 'name' parameter
}

func customLogMiddleware(h httperror.Handler) httperror.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) error {

		// TODO: custom pre-request actions such as wrapping the response writer.

		err := h.Serve(w, r)

		if err != nil {
			// TODO: insert your application's error logging code here.
			fmt.Printf("HTTP Handler returned error %s\n", err)
		}

		return err
	}
}

Example: Standard Middleware

Because httperror.Handler implements the standard http.Handler interface, you can apply any of the many middleware created by the Go community for standard http Handlers, as illustrated in the example below.

Note however, the resulting handlers after wrapping will be http.Handlers, and will therefore not be able to return an error or accept additional parameters. The httprouter example shows hows to use ApplyStandardMiddleware to apply standard middleware to httperror.Handlers without changing their signature.

package httperror_test

import (
	"fmt"
	"net/http"
	"os"

	"github.com/johnwarden/httperror"
	gorilla "github.com/gorilla/handlers"
)

func Example_applyMiddleware() {
	// This is the same helloHandler as the introduction.
	h := httperror.HandlerFunc(helloHandler)

	// Apply some middleware
	sh := gziphandler.GzipHandler(helloHandler)
	sh := gorilla.LoggingHandler(os.Stdout, h)

	_, o := testRequest(sh, "/hello?name=Beautiful")
	fmt.Println(o)
	// Outputs a log line plus
	// Hello, Beautiful
}

Example: HTTPRouter

This example illustrates the use of the error-returning paradigm described in this document with a popular router package, github.com/julienschmidt/httprouter. To make things more interesting, the handler function accepts its parameters as a struct instead of a value of type httprouter.Params, thereby decoupling the handler from the router.

Further, we illustrate the use of ApplyStandardMiddleware to wrap our handler with middleware written for a standard http.Handler, but still allow our third parameter to be passed in by the router.

import (
	"fmt"
	"net/http"

	"github.com/johnwarden/httperror"
	"github.com/julienschmidt/httprouter"
	"github.com/NYTimes/gziphandler"
)

func Example_httprouter() {
	router := httprouter.New()

	// first, apply middleware that parses request params and
	// converts our handler into an httprouter.Handle
	h := requestParserMiddleware(helloRouterHandler)

	// next, apply some middleware. We still have an httprouter.Handle
	h := httperror.ApplyStandardMiddleware[HelloParams](h, gziphandler.GzipHandler)

	router.GET("/hello/:name", h)

	_, o := testRequest(router, "/hello/Sunshine")
	fmt.Println(o)
	// Output: Hello, Sunshine
}

type HelloParams struct {
	Name string
}

// This helloRouterHandler func looks like the standard http Handler, but it takes
// a third argument of type HelloParams argument and can return an error.

func helloRouterHandler(w http.ResponseWriter, r *http.Request, ps HelloParams) error { 
	if ps.Name == "" { 
		return httperror.NewPublic(http.StatusBadRequest, "missing 'name' parameter") 
	}

	fmt.Fprintf(w, "Hello, %s\n", ps.Name)

	return nil
}

// requestParserMiddleware wraps a handler function of type httperror.XHandler[HelloParams]
// and converts it into a httprouter.Handle. The resulting function
// converts its argument of type httprouter.Params into a value of type HelloParams,
// and passes it to the inner handler function. 

func requestParserMiddleware(h httperror.XHandler[HelloParams]) httprouter.Handle {
	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

		var params HelloParams
		params.Name = ps.ByName("name")

		err := h(w, r, params)
		if err != nil {
			httperror.DefaultErrorHandler(w, err)
		}
	}
}

Example: Panic Middleware

This example shows how to use [httperror.PanicMiddleware] (https://pkg.go.dev/github.com/johnwarden/httperror#PanicMiddleware) to serve an appropriate error page to the user on panic and then trigger a clean HTTP server shutdown.

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/johnwarden/httperror"
)


const maxShutDownTimeout = 5 * time.Second

func main() {
	httpServer := &http.Server{
		Addr: ":8080",
	}

	shutdown := func() {
		// shut down the HTTP server with a timeout in case the server doesn't want to shut down.
		// or waiting for connections to change to idle status takes too long.
		ctxWithTimeout, cancel := context.WithTimeout(context.Background(), maxShutDownTimeout)
		defer cancel()
		err := httpServer.Shutdown(ctxWithTimeout)
		if err != nil {
			// if server doesn't respond to shutdown signal, nothing remains but to panic.
			log.Panic(err)
		}
	}

	errorHandler := func(w http.ResponseWriter, err error) {
		if errors.Is(err, httperror.Panic) {
			// the shutdown function must be called in a goroutine. Otherwise, if it is used
			// to shutdown the server, we'll get a deadlock with the server shutdown function
			// waiting for this request handler to finish, and this request waiting for the
			// server shutdown function.
			fmt.Println("Shutting down")
			go shutdown()
		}

		// Now make sure we serve the user an appropriate error page.
		httperror.DefaultErrorHandler(w, err)
	}

	// This middleware converts panics to errors.
	h := httperror.PanicMiddleware(getMeOuttaHere)

	// This is the same helloHandler as the introduction. Add a custom error handler.
	httpServer.Handler = httperror.WrapHandlerFunc(h, errorHandler)

	err := httpServer.ListenAndServe()
}

var getMeOuttaHere = httperror.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
	w.Header().Set("Content-Type", "text/plain")
	panic("Get me outta here!")
	return nil
})

About

Golang package for returning errors instead of handling them directly

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages