Request Cancellation

Handling HTTP request cancellation in Go #

A common issue when using the HTTP server in Golang is distinguishing and properly handling errors originating from the request being canceled. Usually, one doesn’t want client initiated cancellation to end up as an error log.

For example, consider the following code.

import (
    "net/http"
)

func main() {
	mux := http.ServeMux{}
	mux.HandleFunc("/long-process", handle)

	server := http.Server{
		Handler: &mux,
	}

	ln, _ := net.Listen("tcp", ":8080")

	server.Serve(ln)
}

func handle(rw http.ResponseWriter, r *http.Request) {
	if err := longProcess(r.Context()); err != nil {
		log.Print("[ERR] failed to do long process: ", err)
		return
	}
	rw.WriteHeader(http.StatusOK)
}

func longProcess(ctx context.Context) error {
	select {
	case <-time.After(time.Second * 45):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

Looking at the output, we’d see the following log as a client disconnects in the middle of the request.

2022/03/15 20:10:17 [ERR] failed to do long process: context canceled

context.Canceled #

One solution would be to match the error returned from longProcess, and handle it properly depending on the type. Using the context.Canceled error we can distinguish cancellation from other errors.

func handle(rw http.ResponseWriter, r *http.Request) {
	if err := longProcess(r.Context()); err != nil {
		switch {
		case errors.Is(err, context.Canceled):
			log.Print("[DEBUG] request was canceled")
		default:
			log.Print("[ERR] failed to do long process: ", err)
		}
		return
	}
	rw.WriteHeader(http.StatusOK)
}

This would output the following log.

2022/03/15 20:13:46 [DEBUG] request was canceled

Custom error type #

We can do better, though, as there are some intrinsic issues with matching the context.Canceled error. For example, what if there was some kind of cancellation done inside of longProcess? Let’s use our own error to make sure that we only match the error originating from the request context.

var (
	errRequestCanceled = errors.New("request canceled")
)

type requestContext struct {
	context.Context
}

func newRequestContext(ctx context.Context) context.Context {
	return &requestContext{ctx}
}

func (r *requestContext) Err() error {
	err := r.Context.Err()
	if err == context.Canceled {
		return errRequestCanceled
	}
	return err
}

func handle(rw http.ResponseWriter, r *http.Request) {
	if err := longProcess(newRequestContext(r.Context())); err != nil {
		switch {
		case errors.Is(err, errRequestCanceled):
			log.Print("[DEBUG] request was canceled")
		default:
			log.Print("[ERR] failed to do long process: ", err)
		}
		return
	}
	rw.WriteHeader(http.StatusOK)
}

Even better. We can now be sure that we only discard cancellation errors that originates from the request, and not from anything further down the call stack.

Middleware #

Okay, we might want to streamline this a bit, so that we don’t need to instantiate a new request context in every handler. Let’s write a middleware to take care of that.

func main() {
	// ...
	mux.HandleFunc("/long-process", middleware(handle))
    // ...
}

func middleware(next http.HandlerFunc) http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		ctx := newRequestContext(r.Context())
		r = r.WithContext(ctx)
		next(rw, r)
	}
}