Context timeout configuration for HTTP handlers with downstream database calls in Go
0 reputation · 01 Mar 2021, 17:12 UTC
0 reputation · 01 Mar 2021, 17:12 UTC
I am designing a Go service where each incoming HTTP request spawns a chain of calls: a database query, an outbound API request, and a cache write. I want a single deadline to govern the whole chain, but I also want individual stages to have their own stricter limits where needed.
My current assumption is that I would derive a base context with context.WithTimeout at the handler entry point, then derive child contexts with shorter deadlines for specific stages, passing each as the first argument rather than storing it in a struct. I know cancellation is hierarchical and cooperative, so each goroutine must select on ctx.Done(), and every derived cancel function must be called to avoid leaks.
What I am unsure about is the configuration boundary: when a child deadline fires, the error is context.DeadlineExceeded, but I need to distinguish "my stage timed out" from "the parent request was cancelled" when mapping to HTTP status codes. I also want the cache write to complete even if the request context is cancelled.
Which pattern is idiomatic for distinguishing child deadline expiry from parent cancellation? Is detaching the cache write via context.WithoutCancel (Go 1.21+) the right tool, and what are its trade-offs? Should stage timeouts be absolute durations or derived from remaining parent time?
33275 reputation · 01 Mar 2021, 22:44 UTC
To tell whether a context.DeadlineExceeded error came from a child stage timeout or from the parent request being cancelled, check the parent context’s error after the stage returns:
if errors.Is(err, context.DeadlineExceeded) {
if parentCtx.Err() != nil {
// parent request cancelled or its deadline expired
} else {
// this stage’s own deadline fired
}
}
For a cache write that must survive request cancellation, use context.WithoutCancel (Go 1.21+) to detach it, then give the detached work its own timeout:
detached := context.WithoutCancel(reqCtx)
// give the cache write a bounded lifetime of its own
cacheCtx, cacheCancel := context.WithTimeout(detached, 200*time.Millisecond)
defer cacheCancel()
// perform the cache write using cacheCtx
Stage timeouts are normally expressed as absolute durations (e.g. 2 s for DB, 50 ms for cache). Deriving them from the parent’s remaining time is only needed when you want to enforce a proportional budget; otherwise absolute values are simpler and more predictable.
context.WithTimeout(parent, d) inherit the parent’s deadline; they can only be stricter, never longer.context.DeadlineExceeded; the only way to differentiate them is to inspect parentCtx.Err() after the child returns.context.WithoutCancel drops cancellation and deadline propagation while preserving values, making it suitable for best‑effort work that should outlive the request.parentCtx.Err() adds negligible overhead and is the idiomatic way to map child‑timeout → HTTP 504 and parent‑cancellation → log‑only or 499/503 handling.context.WithoutCancel frees the detached work from the request deadline, but you must impose a new timeout (or risk unbounded execution) and consider that the work may outlive the handler, complicating graceful shutdown and tracing.reqCtx, reqCancel := context.WithTimeout(r.Context(), 5*time.Second); defer reqCancel().stageCtx, stageCancel := context.WithTimeout(reqCtx, stageLimit); defer stageCancel().context.DeadlineExceeded, inspect reqCtx.Err() to decide whether to treat it as a stage timeout or a parent cancellation.detached := context.WithoutCancel(reqCtx), then apply its own timeout: cacheCtx, cacheCancel := context.WithTimeout(detached, 200*time.Millisecond); defer cacheCancel().stageCancel and cacheCancel is deferred on all paths (success and error) to prevent timer goroutine leaks.parentCtx.Err() == nil and the stage error is context.DeadlineExceeded.go vet and a goroutine‑leak detector (e.g. goleak) to confirm all cancel functions are invoked.go version; context.WithoutCancel is available only from Go 1.21 onward.If you are unsure whether your environment runs Go 1.21 or later, please confirm the Go version; without context.WithoutCancel you would need an alternative detached context (e.g. context.Background() with request‑scoped values) and its own timeout.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.