Request IDs in Context
The last two lessons wrapped every handler in middleware that acts around
the request — recovering panics, logging status codes. This one wraps every
handler to inject something into the request: a request ID, read from an
incoming X-Request-ID header and made available to every handler downstream
without adding a parameter to a single function signature. The vehicle is
context, which the Fundamentals middleware lesson introduced as the
standard way to carry request-scoped values through a call chain. This lesson
doesn't re-teach context.Context itself — it applies WithValue and
Value to a concrete, common problem.
context as storage, not a parameter bag
It's tempting to treat context.WithValue as a general escape hatch for
passing anything you don't feel like threading through function arguments.
Resist that. The Go team's own guidance is narrower: context values should
carry request-scoped data that crosses API boundaries — things like a
request ID, an authenticated user, or a deadline — not optional parameters
or dependencies a function actually needs to do its job. If a value is
required for correctness, put it in the signature where the compiler can
check it. A request ID is the textbook case for WithValue: middleware sets
it once, arbitrarily many handlers and loggers downstream might want it, and
none of them require it to function.
Why the key can't be a bare string
context.WithValue(ctx, key, value) matches later reads by key equality.
If the key were a plain string like "requestID", any other package that
also used context.WithValue(ctx, "requestID", ...) — for an unrelated
reason — would silently overwrite or be overwritten by yours. Two identical
string keys are indistinguishable to context, even if they come from code
that has never heard of each other.
The fix is a private type:
type ctxKey struct{}
var requestIDKey ctxKey
ctxKey is unexported, so no other package can construct a value of that
type — Go's export rules make it impossible to write ctxKey{} outside this
package. That guarantees requestIDKey can never be == to a key some other
package defines, no matter what they name theirs. This is the standard idiom
for context keys in Go, and it's worth using even for a single value: it
costs one extra type declaration and removes an entire category of bug.
Setting the value: r.WithContext
*http.Request doesn't expose a way to mutate its context in place —
context.Context values are immutable once created. Instead,
r.WithContext(ctx) returns a new *http.Request, a shallow copy of r
with its context field replaced by ctx. Middleware has to pass that new
request to next, not the original:
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
r.Context() reads the current context off the incoming request — often
context.Background() at the top of the chain, or whatever an earlier piece
of middleware already attached. WithValue wraps it in a new context that
also holds id, and WithContext builds the request that carries it. Miss
either step — call next.ServeHTTP(w, r) with the original r, say — and
the value never reaches the handler at all.
Reading it back: comma-ok
ctx.Value(key) returns any, so getting a string back out requires a
type assertion. The comma-ok form is the one to reach for:
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}
If the key was never set — a context that never passed through
RequestID, like in a unit test calling this directly — ctx.Value returns
nil, and asserting nil to string with the comma-ok form yields the
zero value, "", with ok false. Discarding ok here is deliberate: an
absent request ID isn't an error, it's just an empty string, and callers can
treat "no ID" and "empty ID" the same way.
Beyond this exercise
Here, an absent header simply produces an empty ID. A production service
usually goes one step further: if X-Request-ID is missing, generate one
(a UUID or similar) before storing it, so every request gets a traceable ID
whether or not the caller supplied one. The context plumbing in this lesson
is exactly what that upgrade builds on — only the source of id changes.
Practice
1. Request IDs in Context
IntermediateA request ID lets you trace one request across logs. Middleware stashes it in the request
contextso any handler downstream can read it without threading a parameter through every function.RequestID(next http.Handler) http.Handler— read theX-Request-IDheader, put it in the request context, and callnextwith the updated request (r.WithContext(ctx)).RequestIDFrom(ctx context.Context) string— return the stored id, or"".
Use an unexported key type (
type ctxKey struct{}) rather than a bare string — that's how you guarantee no other package's context value collides with yours.package main import ( "context" "net/http" ) // RequestID copies the X-Request-ID header into the request context so // downstream handlers (and logs) can read it via RequestIDFrom. func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // TODO: read the X-Request-ID header, store it in the request // context under an unexported key type, and call next with the // updated request. next.ServeHTTP(w, r) }) } // RequestIDFrom returns the request ID stored in ctx, or "" if there is none. func RequestIDFrom(ctx context.Context) string { // TODO: read the request ID back out of ctx using a comma-ok type // assertion. return "" } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.