Context in Practice
Assumes 04-concurrency/02-coordination/03-context — you built a cancellable
worker there with WithCancel, Done(), and Err(). This lesson doesn't
re-teach any of that. It's about the parts of context that come up NEXT,
once you're writing real request-handling code instead of a toy worker.
WithValue: request-scoped metadata, not parameters
context.WithValue(parent, key, value) attaches data to a context that
flows down through every function the context is passed to. It exists for
exactly one kind of data: information about the request itself that
cuts across your whole call chain — a trace ID for logging, an
authenticated user identity, a deadline's originating reason. It is not a
substitute for function parameters. If a value is something the function
actually needs to do its job — a config struct, a database handle, an
input to compute with — pass it explicitly. Burying it in a context turns
your function signature into a lie: the compiler can no longer tell you
what the function depends on, and every caller has to read the
implementation to find out. The rule of thumb: if removing the value would
change the answer, it's a parameter; if it would only change what you
can log or authorize, it can live in the context.
Keys deserve care too. A WithValue key that's a plain string collides
with any other package that happens to pick the same string — silently,
since two different packages' "userID" keys are indistinguishable to the
map underneath. The fix is a private, unexported type:
type ctxKey struct{ name string }
var userIDKey = ctxKey{"userID"}
ctx = context.WithValue(ctx, userIDKey, "u-42")
id, ok := ctx.Value(userIDKey).(string)
Because ctxKey is unexported, no other package can construct a
colliding value, even if it also defines a struct type named ctxKey.
WithCancelCause and context.Cause: the "why"
You already know cancel() stops a context and ctx.Err() reports
context.Canceled or context.DeadlineExceeded. What Err() can't tell
you is why — was this cancelled because the client hung up, because a
sibling goroutine hit a fatal error, because a rate limit tripped? Go 1.21
added context.WithCancelCause, whose cancel function takes an error:
ctx, cancel := context.WithCancelCause(parent)
cancel(fmt.Errorf("upstream quota exhausted"))
Anywhere downstream, context.Cause(ctx) retrieves that error. If the
context was cancelled with a plain cancel() — no cause given — or via a
parent's ordinary WithCancel, context.Cause falls back to returning
whatever ctx.Err() would return. That fallback means calling
context.Cause is never worse than calling Err(), only ever
potentially more informative — so once you're past a plain Err() check,
prefer Cause. The timeout counterpart is context.WithTimeoutCause,
which lets you distinguish "this specific operation's budget ran out"
from a generic DeadlineExceeded further up the chain.
Deadlines only shrink, they never stretch
Every With* constructor derives a child context from a parent. A
child's deadline can only be tighter than its parent's — if the parent
already has 2s left and you call WithTimeout(ctx, 10*time.Second), the
child still expires with the parent, because context.WithTimeout takes
the earlier of the two deadlines. There's no operation that extends a
deadline outward; that's deliberate; it means a caller can always bound
the total time a call tree is allowed to run, no matter how many layers
below it try to ask for more.
This is also why ctx context.Context as the first parameter of a
function is a convention worth taking seriously, not a style nit. It's an
API contract: every function that does I/O, blocks, or calls other
functions that might, should accept a ctx and pass it straight through
to whatever it calls next. Skip a layer and you've created a chunk of
work that cancellation and deadlines can't reach — a leak in an otherwise
sound system.
Practice
1. Drain, and Say Why You Stopped
IntermediateYou already know WithCancel, Done(), and Err() from the concurrency part. Go 1.21 added the missing piece: why was the work cancelled?
context.WithCancelCauselets the canceller attach a real error, andcontext.Cause(ctx)retrieves it — wherectx.Err()can only ever say "context canceled".Implement
Drain(ctx context.Context, jobs <-chan string) ([]string, error):- Collect values from
jobsuntil it's closed — return everything collected and a nil error. - If
ctxis cancelled first, stop and return what you have so far pluscontext.Cause(ctx)— NOTctx.Err(). The tests cancel with a custom cause and check they get it back. - With a plain
WithCancel,context.Causefalls back toctx.Err()— so using Cause is strictly more informative, never less.
The two-value receive
v, ok := <-jobsis how you notice the close.package main import ( "context" "fmt" ) // Drain collects values from jobs until the channel is closed — everything // collected, nil error — or ctx is cancelled first, returning what was // collected so far and the context's CAUSE. context.Cause(ctx) surfaces // the rich error passed to a WithCancelCause cancel function; ctx.Err() // would only ever say "context canceled". func Drain(ctx context.Context, jobs <-chan string) ([]string, error) { // TODO: loop over a select: ctx.Done() → return collected + // context.Cause(ctx); receive v, ok from jobs → ok==false means closed // (return collected, nil), otherwise append and keep looping. return nil, nil } func main() { jobs := make(chan string, 2) jobs <- "a" jobs <- "b" close(jobs) got, err := Drain(context.Background(), jobs) fmt.Println(got, err) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Collect values from