Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

Threading context.Context Through, And Where It Goes Wrong

How I learned to pass context.Context the right way in Go, and the three mistakes I made first.

Code on a screen

The rule everyone quotes is "context is the first argument, and you never store it in a struct". Fine. The trouble is that the rule tells you the shape of the answer without telling you why, so the first time you hit something awkward you reach for the wrong tool. I made all the obvious mistakes, so here's the short version of what they cost me.

The first thing that finally landed: context.Context is for cancellation and deadlines, and request-scoped values you can afford to lose. That's it. It is not a bag for passing dependencies around because threading them properly is tedious. The moment you put your database handle in there with context.WithValue, you've built a typed-as-any service locator and turned a compile error into a nil panic at runtime.

Programming workspace

The second: cancellation only works if everyone along the chain actually respects it. A context cancelling does nothing on its own. Some function down the stack has to be selecting on ctx.Done(), or passing the context into a call that does. I had a "timeout" that didn't, because the slow bit was a third-party client I'd called without handing it the context at all. The deadline was set, beautifully, and ignored completely.

func fetch(ctx context.Context, id string) (*Record, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlFor(id), nil)
    if err != nil {
        return nil, err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetch %s: %w", id, err)
    }
    defer resp.Body.Close()
    return decode(resp.Body)
}

The fix is unglamorous: NewRequestWithContext rather than NewRequest, and pass ctx all the way down. If a function does I/O or blocks, it takes a context. If it's pure computation, it doesn't need one, and adding one is just noise.

The third mistake was forgetting defer cancel(). When you call context.WithTimeout or WithCancel, you get a cancel function back, and you have to call it even on the success path. Leak enough of those and you've got a slow drip of timers and goroutines that the runtime cheerfully keeps alive on your behalf. go vet catches a lot of these now, which is the kind of small mercy I've learned to be grateful for.

Where it clicks is when you stop thinking of context as a parameter you're obliged to pass and start thinking of it as the lifetime of the work. The HTTP handler creates it when the request arrives. It flows down through everything that request touches. When the client hangs up or the deadline hits, the whole tree gets the news at once and unwinds. Threaded properly, you get cancellation for free across layers you didn't have to coordinate by hand.

The discipline is dull and the payoff is real. Pass it down, respect Done, cancel what you create, and keep your dependencies out of it. Everything else is detail.