Context in HTTP Servers

r.Context() is cancelled when the client disconnects or the handler returns, so stop working when nobody is listening.

Syntaxctx := r.Context() // cancelled on client disconnect or when ServeHTTP returns

A handler is a function the server calls once per incoming request. It has the signature func(http.ResponseWriter, *http.Request): w is where the reply is written and r describes what was asked for.

Every server request arrives with a context already attached, reached with r.Context(). You do not create it. The server cancels it when the client's connection closes, when the request is cancelled (which HTTP/2 can do on its own), or when ServeHTTP returns.

Use it, do not replace it

Pass r.Context() into every call the handler makes: queries, outbound HTTP requests, waits on a channel. A client that hangs up then stops the database work it caused, instead of leaving it to finish writing a reply nobody will read.

To add to the context, derive a child and put it back on a copy of the request:

ctx := context.WithValue(r.Context(), keyType{}, v)
r = r.WithContext(ctx)

r.WithContext(ctx) returns a shallow copy of the request with the new context. r.Clone(ctx) does the same with a deep copy of the headers and URL, which you need if the copy will be modified while the original is still in use. Both require a non-nil context.

What trips people up

The context dies with the handler. A goroutine started inside a handler and still running after it returns sees a cancelled context, so its work fails for no visible reason. Derive context.WithoutCancel(r.Context()) (Go 1.21) for that, and give it a timeout of its own.

To watch this live, register the handler below with http.HandleFunc("/slow", slow), start http.ListenAndServe(":8080", nil), run curl http://localhost:8080/slow and press Ctrl-C.

Example

Example · go
package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
)

// slow replies only if the client is still connected.
func slow(w http.ResponseWriter, r *http.Request) {
	// A real handler would receive its finished work on this channel.
	work := make(chan string, 1)
	select {
	case result := <-work:
		fmt.Fprintln(w, result)
	case <-r.Context().Done():
		// Nobody is listening, so writing to w would be wasted.
		fmt.Println("abandoned:", r.Context().Err())
	}
}

func main() {
	// Stand in for a client that has already hung up.
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	r := httptest.NewRequest(http.MethodGet, "/slow", nil).WithContext(ctx)
	w := httptest.NewRecorder()
	slow(w, r)

	fmt.Println("bytes written:", w.Body.Len())
}

// Output:
// abandoned: context canceled
// bytes written: 0

When to use it

  • Stop a slow report query when the browser that asked for it gives up.
  • Thread the request context into every database call a handler makes.
  • Attach a request id in one wrapper and read it in logging code deep below.
  • Keep a background email send alive after the handler has already replied.

More examples

Handing the request context to an outbound call

This is the join between the two halves of the context package: the server gives you a context, the client takes one. Deriving the call's timeout from r.Context() rather than from context.Background() is what makes a disconnected client stop the upstream request too. Without it, a client that goes away still costs you a full two seconds of upstream work.

Example · go
func stockHandler(w http.ResponseWriter, r *http.Request) {
	// Two seconds at most, and sooner if the client disconnects, because
	// this context is derived from the request's.
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	url := "http://inventory:8080/stock"
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		http.Error(w, "bad upstream request", http.StatusInternalServerError)
		return
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "upstream unavailable", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()

	if _, err := io.Copy(w, resp.Body); err != nil {
		log.Printf("stock: copying reply: %v", err)
	}
}

A wrapper that adds a value to the request

A wrapper like this is how per-request data gets into a context in a server: derive a child, build a copy of the request around it, and call the next handler with the copy. Note that the original r is untouched, which is why the new request value has to be the one passed on. Use http.HandlerFunc to turn a plain function into an http.Handler.

Example · go
type requestIDKey struct{}

// tagRequest wraps next, giving every request an id in its context.
func tagRequest(id string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		// Shallow copy of r with the new context. The longhand is:
		//   r2 := r.WithContext(ctx)
		//   next.ServeHTTP(w, r2)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// requestID reads the id back, anywhere below the wrapper.
func requestID(r *http.Request) (string, bool) {
	id, ok := r.Context().Value(requestIDKey{}).(string)
	return id, ok
}

Work that must outlive the request

Fire-and-forget work started in a handler is the classic source of mysterious context canceled errors: the handler returns, the context dies, and the goroutine's first call fails. WithoutCancel, added in Go 1.21, keeps any request id or trace id in the context while cutting the cancellation link. For work that must survive a restart as well, a queue is the better answer.

Example · go
func submitHandler(w http.ResponseWriter, r *http.Request) {
	// r.Context() is cancelled as soon as this function returns, so a
	// goroutine that keeps working needs a context that survives it.
	// WithoutCancel keeps the values and drops the cancellation.
	ctx := context.WithoutCancel(r.Context())
	// Give the background work a limit of its own, or it has none.
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)

	go func() {
		defer cancel()
		if err := sendReceipt(ctx); err != nil {
			log.Printf("receipt: %v", err)
		}
	}()

	w.WriteHeader(http.StatusAccepted)
}

Discussion

  • Be the first to comment on this lesson.