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)
}
}