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.
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 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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- AccessLog, before next. AccessLog wraps the ResponseWriter in a recorder that will catch the status, notes the time, and calls next with the wrapper.
- Recover, before next. Recover defers a function that will catch a panic from anything below it, then calls next.
- 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.
- Back up, inside out. The handler returns, and control climbs back in reverse order. Recover's deferred function runs first and finds no panic.
- 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.
- 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
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 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
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: 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- Nothing to unwrap. statusWriter has neither, so Flush returns an error that matches http.ErrNotSupported, feature not supported, as wrapper.go printed.
- 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.
- 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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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:
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)
}
} 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.
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:
-
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.
-
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.
-
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.
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.WithContextreturned. - 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
- Package net/http: Handler (The Go Authors)
- Package net/http: ResponseController (The Go Authors)
- Package net/http: ResponseWriter (The Go Authors)
- Package net/http: Request.WithContext (The Go Authors)
- Go 1.20 Release Notes: HTTP ResponseController (The Go Authors)
- Package context: WithValue (The Go Authors)
- The Go Programming Language Specification: Handling panics (The Go Authors)
- Package crypto/subtle: ConstantTimeCompare (The Go Authors)
- REST Security Cheat Sheet: security headers and error handling (OWASP Foundation)
- Error Handling Cheat Sheet (OWASP Foundation)
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress