Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Go  Module 18 – HTTP servers and clients with net/http

Middleware, request context and ResponseController

Write Go HTTP middleware in the order you mean, pass request IDs through the request context, and wrap a ResponseWriter that still flushes and keeps deadlines.

  • Intermediate
  • 25 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 10 of 25 minutes free
Free: Middleware wraps a handler in another handler · Request-scoped values travel in the request context · A wrapped writer hides methods, and ResponseController finds them. With a pass: 2 in-depth parts and 3 interview answers, about 15 minutes.

What you will learn

  • Write middleware as func(http.Handler) http.Handler, and compose a chain in the order you intend
  • Pass request-scoped values through the request context under an unexported key type
  • Wrap a ResponseWriter without hiding its methods, and reach them with http.ResponseController
  • Recover from handler panics with a generic 500, and log each request once with its status
  • Write a token check that stops the chain, and give one response a write deadline of its own

Before you start

What you’ll learn · Before you start

What you will learn

  • Write middleware as func(http.Handler) http.Handler, and compose a chain in the order you intend
  • Pass request-scoped values through the request context under an unexported key type
  • Wrap a ResponseWriter without hiding its methods, and reach them with http.ResponseController
  • Recover from handler panics with a generic 500, and log each request once with its status
  • Write a token check that stops the chain, and give one response a write deadline of its own

Before you start

On this page

A library API has 30 routes, and every one needs the same four things: a request ID, an access-log line, a 500 instead of a dropped connection when a handler panics, and security headers. Written into each handler, that is 120 copies that must stay alike. middleware: A function that takes an http.Handler and returns another one, which runs code before and after calling the first. Middlewares nest, so the outermost sees a request first and finishes last. writes each one once, as a handler that wraps another handler.

You will compose middleware in the order you mean, carry values in the request context, and wrap a ResponseWriter without hiding what it can do. Every program here ran on Go 1.27.1.

Middleware wraps a handler in another handler

A middleware receives the handler to call next and gives back a handler of its own, func(next http.Handler) http.Handler. The handler it returns runs some code, calls next.ServeHTTP(w, r), and may run more code once next returns. Because the result is again a Handler, middlewares nest: RequestID(AccessLog(Recover(mux))) is one Handler, which the server calls for every request. chain.go wraps three tracing middlewares around one handler, first by nesting the calls, then with two helpers that take a list:

chain.go: three middlewares in one chain, and a loop that reverses them Go · chain.go
// chain.go: three middlewares wrapped around one handler, the order they run in, and a chain helper written twice,
// once right and once with the loop that reverses the order.
package main

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

// Middleware takes the next handler and returns a handler that runs code around it.
type Middleware func(http.Handler) http.Handler

// trace is a middleware that only says when a request enters it and when it leaves.
func trace(name string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Println("  enter", name)
			next.ServeHTTP(w, r)
			fmt.Println("  leave", name)
		})
	}
}

// chain applies the middlewares so that the first one listed is the outermost, the first to see a request.
func chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

// chainInListOrder wraps in list order, which leaves the last middleware listed outermost.
func chainInListOrder(h http.Handler, mws ...Middleware) http.Handler {
	for _, mw := range mws {
		h = mw(h)
	}
	return h
}

func main() {
	final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("    the handler runs")
	})
	send := func(title string, h http.Handler) {
		fmt.Println(title)
		h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/books/42", nil))
	}

	send("RequestID(AccessLog(Recover(final))):", trace("RequestID")(trace("AccessLog")(trace("Recover")(final))))
	send("chain(final, RequestID, AccessLog, Recover):", chain(final, trace("RequestID"), trace("AccessLog"), trace("Recover")))
	send("chainInListOrder(final, RequestID, AccessLog, Recover):", chainInListOrder(final, trace("RequestID"), trace("AccessLog"), trace("Recover")))
}

Output

RequestID(AccessLog(Recover(final))):
  enter RequestID
  enter AccessLog
  enter Recover
    the handler runs
  leave Recover
  leave AccessLog
  leave RequestID
chain(final, RequestID, AccessLog, Recover):
  enter RequestID
  enter AccessLog
  enter Recover
    the handler runs
  leave Recover
  leave AccessLog
  leave RequestID
chainInListOrder(final, RequestID, AccessLog, Recover):
  enter Recover
  enter AccessLog
  enter RequestID
    the handler runs
  leave RequestID
  leave AccessLog
  leave Recover

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run chain.go

The nested calls and chain ran in the same order: RequestID entered first and left last, and Recover, the innermost, entered just before the handler. chainInListOrder wrapped in list order, which left the last middleware listed outermost, so Recover ran first: the same three functions in reverse, from one changed loop. Follow one request down the chain and its reply back up:

Interactive · stage by stage One request down the chain and its reply back up Step down RequestID, AccessLog and Recover with GET /books/42, then back up with its reply.
One request down the chain and its reply back up, step 8 of 8. Showing The request arrives, RequestID (ID req-0001 in the context), AccessLog (logged GET /books/42 200), Recover (nothing to recover), The handler. In focus: RequestID.The request arrivesGET /books/42RequestIDID req-0001 in the contextAccessLoglogged GET /books/42 200Recovernothing to recoverThe handlerGET /books/{id}

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The outermost runs first. The server hands the request to the outermost handler. main built RequestID(AccessLog(Recover(mux))), so RequestID wraps everything and sees the request first.
  2. RequestID, before next. RequestID makes an ID, sets it as the X-Request-Id header of the reply, and calls next with a request whose context holds the ID.
  3. AccessLog, before next. AccessLog wraps the ResponseWriter in a recorder that will catch the status, notes the time, and calls next with the wrapper.
  4. Recover, before next. Recover defers a function that will catch a panic from anything below it, then calls next.
  5. The handler runs last. The handler writes book 42 to the wrapped writer. None of the code after next in the three middlewares has run yet.
  6. Back up, inside out. The handler returns, and control climbs back in reverse order. Recover's deferred function runs first and finds no panic.
  7. AccessLog, after next. AccessLog's code after next runs now. It reads 200 from its recorder and the ID from the context, and writes its log line.
  8. RequestID leaves last. RequestID returns last, and the reply leaves with its X-Request-Id header. Entering went outside in and leaving goes inside out, as chain.go printed.

Key idea

The outermost middleware sees each request before every other layer and finishes after all of them; write the chain from outside in, and let a helper keep that order.

Checkpoint

Check: Middleware and the order of a chain

  1. Question 1 of 2 A server runs RequestID(AccessLog(Recover(mux))). Whose code before next runs first for each request?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: RequestID's, because it is the outermost wrapper

    Each middleware wraps the handler it is given, so the expression nests like the calls: RequestID enters first, then AccessLog, then Recover, then the mux; on the way back the order reverses, as chain.go printed.

  2. Question 2 of 2 A helper wraps with for _, mw := range mws { h = mw(h) } and is called with RequestID, AccessLog and Recover, in that order. Which middleware sees the request first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Recover, because each pass wraps the previous result, so the last one listed ends up outermost

    Wrapping in list order reverses the list. chain wraps from the last element to the first, so the first middleware listed is the outermost, which is how most readers expect a list of middlewares to read.

Request-scoped values travel in the request context

A middleware hands data inward by attaching it to the request context. context.WithValue makes a context that carries one more value, and r.WithContext(ctx) returns a copy of the request with that context. The key is a value of an unexported type, so no other package can make an equal one, and a small function such as RequestIDFrom gives callers a typed result, as context basics showed. requestid.go tries three stacks:

requestid.go: a request ID in the context, read inside and outside Go · requestid.go
// requestid.go: a middleware that puts a request ID into the request context, the handler and the log line that read
// it, and two slips: a log middleware placed outside RequestID, and a RequestID that throws WithContext's result away.
package main

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

// ctxKey is unexported, so no other package can make a key equal to requestIDKey.
type ctxKey int

const requestIDKey ctxKey = 0

// RequestIDFrom returns the ID that RequestID stored, and whether there is one.
func RequestIDFrom(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey).(string)
	return id, ok
}

var counter atomic.Int64 // middleware runs on many goroutines at once

// RequestID gives each request an ID: in a response header for the client, in the context for the code inside.
func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := fmt.Sprintf("req-%04d", counter.Add(1))
		w.Header().Set("X-Request-Id", id)
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx)) // WithContext returns a new request: pass that one on
	})
}

// RequestIDLost builds the same context, but passes the old request on.
func RequestIDLost(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := fmt.Sprintf("req-%04d", counter.Add(1))
		w.Header().Set("X-Request-Id", id)
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		r.WithContext(ctx) // the new request is thrown away
		next.ServeHTTP(w, r)
	})
}

// AccessLog writes one line per request, with the ID it finds in the context.
func AccessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(w, r)
		id, ok := RequestIDFrom(r.Context())
		if !ok {
			id = "(none in the context)"
		}
		fmt.Printf("  log: %s %s id=%s\n", r.Method, r.URL.Path, id)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /books/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, ok := RequestIDFrom(r.Context())
		if !ok {
			id = "(none in the context)"
		}
		fmt.Printf("  handler: book %s, request ID %s\n", r.PathValue("id"), id)
	})

	stacks := []struct {
		name string
		h    http.Handler
	}{
		{"RequestID(AccessLog(mux))", RequestID(AccessLog(mux))},
		{"AccessLog(RequestID(mux))", AccessLog(RequestID(mux))},
		{"RequestIDLost(AccessLog(mux))", RequestIDLost(AccessLog(mux))},
	}
	for _, s := range stacks {
		fmt.Println(s.name + ":")
		rec := httptest.NewRecorder()
		s.h.ServeHTTP(rec, httptest.NewRequest("GET", "/books/42", nil))
		fmt.Println("  response header X-Request-Id:", rec.Header().Get("X-Request-Id"))
	}
}

Output

RequestID(AccessLog(mux)):
  handler: book 42, request ID req-0001
  log: GET /books/42 id=req-0001
  response header X-Request-Id: req-0001
AccessLog(RequestID(mux)):
  handler: book 42, request ID req-0002
  log: GET /books/42 id=(none in the context)
  response header X-Request-Id: req-0002
RequestIDLost(AccessLog(mux)):
  handler: book 42, request ID (none in the context)
  log: GET /books/42 id=(none in the context)
  response header X-Request-Id: req-0003

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run requestid.go

With RequestID outside AccessLog, the handler and the log line both read req-0001. With AccessLog outside, the handler still saw its ID, but AccessLog read the request it had been given, which never carried the value: a value in the request context travels inward only. The response header got all three IDs, so RequestIDLost did run; it built the context, then passed the old request on.

Calling WithContext and keeping the old request

r.WithContext(ctx) changes nothing about r; it returns a new request. Pass that one on, in one line: next.ServeHTTP(w, r.WithContext(ctx)). The lost version compiles, and its tests pass unless they read the value.

The server cancels a request context when the client’s connection closes or ServeHTTP returns.

Checkpoint

Check: Values in the request context

  1. Question 1 of 2 RequestIDLost calls r.WithContext(ctx) with the new ID and then next.ServeHTTP(w, r). The handler finds no ID. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: WithContext returns a new request and leaves r unchanged, so the old request, without the ID, went on

    A request's context is changed by making a new request, r.WithContext(ctx), and passing that one on. The response header still had the ID, which shows the middleware ran; only the context of the request that went on lacked it.

  2. Question 2 of 2 AccessLog(RequestID(mux)) logged id=(none in the context), while the handler saw req-0002. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: AccessLog holds the request it received, which is outside RequestID; the value exists only in the request RequestID passed inward

    A middleware can only read values that an outer middleware added. Put RequestID outside everything that needs the ID, or have the outer code read it from the response header that RequestID set.

A wrapped writer hides methods, and ResponseController finds them

An access log needs the status the handler sends, which a ResponseWriter cannot report, so the middleware wraps the writer in a struct that embeds it and records WriteHeader. Embedding the interface brings its three methods and no more, so the wrapper hides the rest of the server’s writer, such as Flush, which sends buffered output at once. http.ResponseController, added in Go 1.20, reaches such methods through wrappers: it calls the method on the writer it was given, or follows that writer’s Unwrap method inward. wrapper.go runs one streaming handler behind three writers, with httptest’s recorder, which has a Flush method too, standing in for the server’s writer:

wrapper.go: Flush through no wrapper, a plain wrapper and one with Unwrap Go · wrapper.go
// wrapper.go: a ResponseWriter wrapper that records the status for an access log, the Flush method it hides, and how
// http.ResponseController reaches that method again once the wrapper has an Unwrap method.
package main

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

// statusWriter records the status a handler sends. Embedding gives it Header, Write and WriteHeader, nothing more.
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (s *statusWriter) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

// unwrapWriter is the same wrapper with one more method: Unwrap returns the writer it wraps.
type unwrapWriter struct {
	statusWriter
}

func (u *unwrapWriter) Unwrap() http.ResponseWriter { return u.ResponseWriter }

// stream is a handler that sends its first line at once and the rest later, as a progress report would.
func stream(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "export started")
	_, isFlusher := w.(http.Flusher)
	err := http.NewResponseController(w).Flush()
	fmt.Printf("  w.(http.Flusher): %t; ResponseController.Flush: %v (ErrNotSupported: %t)\n",
		isFlusher, err, errors.Is(err, http.ErrNotSupported))
}

func main() {
	writers := []struct {
		name string
		wrap func(http.ResponseWriter) http.ResponseWriter
	}{
		{"the server's writer, unwrapped", func(w http.ResponseWriter) http.ResponseWriter { return w }},
		{"statusWriter around it", func(w http.ResponseWriter) http.ResponseWriter {
			return &statusWriter{ResponseWriter: w, status: http.StatusOK}
		}},
		{"unwrapWriter around it", func(w http.ResponseWriter) http.ResponseWriter {
			return &unwrapWriter{statusWriter{ResponseWriter: w, status: http.StatusOK}}
		}},
	}
	for _, wr := range writers {
		fmt.Println(wr.name + ":")
		rec := httptest.NewRecorder() // stands in for the server's writer, which has a Flush method too
		stream(wr.wrap(rec), httptest.NewRequest("GET", "/export", nil))
		fmt.Printf("  Flush reached the writer underneath: %t\n", rec.Flushed)
	}
}

Output

the server's writer, unwrapped:
  w.(http.Flusher): true; ResponseController.Flush: <nil> (ErrNotSupported: false)
  Flush reached the writer underneath: true
statusWriter around it:
  w.(http.Flusher): false; ResponseController.Flush: feature not supported (ErrNotSupported: true)
  Flush reached the writer underneath: false
unwrapWriter around it:
  w.(http.Flusher): false; ResponseController.Flush: <nil> (ErrNotSupported: false)
  Flush reached the writer underneath: true

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run wrapper.go

statusWriter failed both ways, and http.ResponseController returned an error matching ErrNotSupported. unwrapWriter still failed the type assertion, but ResponseController followed Unwrap and flushed. Step through both:

Interactive · step by step How a wrapper hides Flush, and how Unwrap gets it back Step through the export handler's Flush, first through statusWriter, then through a wrapper with an Unwrap method.
How a wrapper hides Flush, and how Unwrap gets it back, step 6 of 6. Showing Export handler, ResponseController (Flush: nil), statusWriter (unwrapWriter: + Unwrap), The server's writer. In focus: ResponseController, The server's writer.Export handlerwants to flush a lineResponseControllerFlush: nilstatusWriterunwrapWriter: + UnwrapThe server's writeralso Flush and HijackFlush()

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The handler holds a wrapper. AccessLog passed the handler a statusWriter, which embeds the server's ResponseWriter. Embedding an interface brings only that interface's three methods.
  2. The assertion fails. The handler asks w.(http.Flusher). statusWriter has no Flush method, although the writer inside it has one, so the assertion fails and the line stays in the buffer.
  3. ResponseController asks. http.NewResponseController(w).Flush() looks for a Flush method on the value it was given, and for an Unwrap method that returns another writer to look in.
  4. Nothing to unwrap. statusWriter has neither, so Flush returns an error that matches http.ErrNotSupported, feature not supported, as wrapper.go printed.
  5. Add an Unwrap method. unwrapWriter is the same wrapper plus Unwrap() http.ResponseWriter, which returns the writer inside. The assertion still fails, since Unwrap adds no Flush.
  6. Through to the server's writer. This time ResponseController follows Unwrap to the server's writer, finds its Flush and calls it. The first line goes out while the handler is still running.

Key idea

Give every ResponseWriter wrapper an Unwrap method, and call optional methods such as Flush through http.ResponseController instead of type assertions.

Checkpoint

Check: Wrapped writers and http.ResponseController

  1. Question 1 of 2 Behind statusWriter, the handler's w.(http.Flusher) was false, though the server's writer has a Flush method. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Embedding http.ResponseWriter in a struct brings that interface's three methods only, so the wrapper has no Flush

    A wrapper hides every optional interface of the writer it wraps, http.Flusher among them, unless it implements each one or offers Unwrap so that http.ResponseController can look further in.

  2. Question 2 of 2 What must a ResponseWriter wrapper have so that http.NewResponseController(w).Flush() reaches the server's writer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An Unwrap() http.ResponseWriter method that returns the writer it wraps

    NewResponseController calls Flush, FlushError, Hijack, the deadline setters or EnableFullDuplex on the writer it is given, and follows Unwrap when that writer lacks them. A wrapper with Unwrap keeps all of them reachable at once.

Five tools of a real service build on these ideas. Recover turns a panic into a 500 that says nothing about the code. An access log writes one line per request, with its status. Security headers tell browsers how to treat each reply. A token check refuses a request without a valid token. SetWriteDeadline gives one long response more time.

Recovery, access logs and security headers, in an order that works Pro: opens with a Pro, Premium or Ultimate pass

Three middlewares of every service fail quietly when they are written or placed wrongly. This part reproduces each failure on a real server and in memory, and gives the version, and the order, that a test can prove right.

In this part:

  • A panic with no recovery, and with two kinds of recovery
  • Where the access log and the recovery go, with the evidence
  • Security headers set before the handler writes
  • Practise: a Recover that hides the panic and passes aborts on

4 runnable examples (Go, Go (recover.go), Go (recover_test.go), Python) · about 6 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

A token check that stops the chain, and a deadline for one response Pro: opens with a Pro, Premium or Ultimate pass

A middleware that refuses requests has to stop them, and a server-wide timeout sometimes has to bend for one handler. This part reproduces a refusal that lets the request through anyway, then measures a stream that the server’s write deadline cuts off, and the one call that saves it.

In this part:

  • A token check that forgets to return, and the one that stops
  • A write deadline for one response, measured on a real server

2 runnable examples (Go) · about 6 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise: write RequestID and a status writer

practice/middleware.go has starters for RequestID and RequestIDFrom, and a StatusWriter that neither records the status nor lets a flush through. The tests check the IDs, the status and the flush:

practice/middleware.go and practice/middleware_test.go

Go (middleware.go) · practice/middleware.go

// Package practice holds two pieces of middleware to finish: RequestID, with RequestIDFrom, and StatusWriter.
package practice

import (
	"context"
	"net/http"
)

// RequestID gives every request its own ID, in the X-Request-Id response header and in the request context.
func RequestID(next http.Handler) http.Handler {
	return next // TODO
}

// RequestIDFrom returns the ID that RequestID put into the context, or "" if there is none.
func RequestIDFrom(ctx context.Context) string {
	return "" // TODO
}

// StatusWriter records the status a handler sends, for an access log. It must keep the methods of the writer it
// wraps, such as Flush, reachable through http.ResponseController.
type StatusWriter struct {
	http.ResponseWriter
	Status int
}

// WriteHeader passes the status on to the writer underneath.
func (s *StatusWriter) WriteHeader(code int) {
	s.ResponseWriter.WriteHeader(code) // TODO: record the status too
}

Go (middleware_test.go) · practice/middleware_test.go

package practice

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

// get sends one request through h and returns what h wrote.
func get(h http.Handler) *httptest.ResponseRecorder {
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/books/42", nil))
	return rec
}

func TestRequestID(t *testing.T) {
	var seen []string
	h := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seen = append(seen, RequestIDFrom(r.Context()))
	}))
	a, b := get(h).Header().Get("X-Request-Id"), get(h).Header().Get("X-Request-Id")
	if a == "" || a == b {
		t.Errorf("X-Request-Id of two requests = %q and %q, want two different IDs", a, b)
	}
	if len(seen) != 2 || seen[0] != a || seen[1] != b {
		t.Errorf("the handlers saw the IDs %q in their contexts, want %q and %q", seen, a, b)
	}
}

func TestStatusWriter(t *testing.T) {
	rec := httptest.NewRecorder()
	sw := &StatusWriter{ResponseWriter: rec, Status: http.StatusOK}
	// What a streaming handler does: a status, a first line, and a flush through http.ResponseController.
	sw.WriteHeader(http.StatusAccepted)
	sw.Write([]byte("export started\n"))
	err := http.NewResponseController(sw).Flush()
	if sw.Status != http.StatusAccepted {
		t.Errorf("Status = %d, want %d, the status the handler sent", sw.Status, http.StatusAccepted)
	}
	if err != nil || !rec.Flushed {
		t.Errorf("Flush through StatusWriter returned %v and reached the writer underneath: %t; want <nil> and true", err, rec.Flushed)
	}
}
The starter middleware against its tests Python · practice_session.py
from session import run

# The starter middleware against its tests: RequestID passes the request on, and StatusWriter records nothing and
# hides Flush.
run(
    """
cd practice && go test .
""",
    expect_failure=("cd practice && go test .",),
)

Output

$ cd practice && go test .
--- FAIL: TestRequestID (0.00s)
    middleware_test.go:23: X-Request-Id of two requests = "" and "", want two different IDs
--- FAIL: TestStatusWriter (0.00s)
    middleware_test.go:38: Status = 200, want 202, the status the handler sent
    middleware_test.go:41: Flush through StatusWriter returned feature not supported and reached the writer underneath: false; want <nil> and true
FAIL
FAIL	example.com/practice	1.168s
FAIL
[exit status 1]

This output changes from run to run: go test prints how long each test and the whole run took, which differs from one run to the next.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

Make go test . pass in the practice folder. A first hint: RequestID needs r.WithContext, and StatusWriter one more method. Then the questions on chains, contexts and wrapped writers, your weakest idea first:

Practice that adapts to you

Practise this lesson

6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 6 In net/http code, what is the usual type of a middleware?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: func(http.Handler) http.Handler

    func(http.Handler) http.Handler composes with any Handler, a ServeMux or another middleware, which is why the standard library and most routers use the same shape.

  2. Question 2 of 6 A chain is RequestID(AccessLog(Recover(mux))), and you add a token check. Refused requests must still be logged with their request ID. Where does the check go?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: Inside AccessLog, for example RequestID(AccessLog(Recover(Token(mux))))

    Order a chain by what each layer must see: IDs and logs outermost so that every request is counted, recovery around everything that can panic, and checks that refuse requests inside the layers that should record them.

  3. Question 3 of 6 Why is a context key given an unexported type of its own, such as type ctxKey int?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: No other package can make a value of that type, so no other package's key can equal it

    With a string key, two packages that both pick "user" overwrite each other's value. An unexported key type, used only through small accessor functions such as RequestIDFrom, makes that impossible.

  4. Question 4 of 6 Which of these belong in the request context? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 4 of 6

    Answer:

    • The request ID that RequestID made
    • The authenticated caller

    Context values are for data that belongs to the request and has to cross API boundaries: IDs, the caller, a trace. Dependencies and ordinary parameters stay explicit.

  5. Question 5 of 6 Why does an access-log middleware wrap the ResponseWriter it passes to the handler?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: To learn the status the handler sends, which the ResponseWriter interface has no method to read back

    A status recorder embeds the writer and overrides WriteHeader, so the status passes through it on its way to the client and the log can read it after next returns. An Unwrap method keeps the writer's other methods reachable.

  6. Question 6 of 6 A streaming handler logs ResponseController.Flush: feature not supported in production but not in a test that calls it directly. What is the likely cause?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: A middleware in production wraps the writer without an Unwrap method, so no writer the controller can reach has Flush

    Test streaming handlers through the same middleware chain production uses. An error matching http.ErrNotSupported means some layer between the handler and the server hides the method; give that layer an Unwrap method.

Interview questions

Warm-up (fresher): What does r.WithContext return, and why must a middleware pass that result on?

It returns a shallow copy of the request whose context is the one you gave it; the original request keeps its old context. A middleware that adds a value with context.WithValue must therefore call next with the new request, next.ServeHTTP(w, r.WithContext(ctx)). Passing the old request on compiles and runs, but no handler inside can see the value.

Three longer questions of the module bank build on this lesson:

  1. Mid to senior

    An export endpoint streamed progress line by line until someone added an access-log middleware. Now clients get the whole export at the end, and nothing logs an error. Explain why, and fix the middleware so that every handler behind it keeps working.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    An audit finds anonymous DELETE requests that succeeded, although every one of them got 401 Unauthorized. The bearer-token middleware is ten lines long, and its tests pass. Find the likely bug, fix it, and make sure it cannot come back.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    Design the middleware chain of a public JSON API: request IDs, access logs, panic recovery, security headers, bearer-token checks and per-route limits. Give the order from outside in, why each layer sits there, and how you would prove the order in tests.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A middleware is func(http.Handler) http.Handler; the outermost wrapper is the first to see a request and the last to finish with it, and a helper should keep the listed order.
  • Request-scoped values ride on the request context under an unexported key type, travel inward only, and reach the handler only through the request that r.WithContext returned.
  • A ResponseWriter wrapper hides the writer’s other methods; an Unwrap method lets http.ResponseController reach Flush and the deadline setters again.

Terms in this lesson

middleware
A function that takes an http.Handler and returns another one, which runs code before and after calling the first. Middlewares nest, so the outermost sees a request first and finishes last.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.