With one shared http.Client, how do you give a single outbound call a tighter deadline?
answer
- put it on the request, not the client
- one client, many budgets
- the tighter of the two wins
- the clock starts when you derive it
- cancel too early and the body dies
basics
~20 sBuild the request with http.NewRequestWithContext and a context from context.WithTimeout, then call the shared client's Do. Both budgets are enforced and the earlier one wins, so a request context can tighten Client.Timeout but never extend it.
solid answer
~50 sYou attach the deadline to the request, not to the client. Create a context with `context.WithTimeout` (or `WithDeadline`), `defer cancel()`, build the request with `http.NewRequestWithContext(ctx, method, url, body)`, and pass it to the shared client's `Do`. Both limits then apply and whichever expires first ends the call — the request context can only shorten the client's ceiling, never raise it. Two details matter in review. First, the clock starts at `context.WithTimeout`, not at `Do`, so creating one context and then doing slow work before the call silently eats the budget. Second, `defer cancel()` in a helper that *returns* the response is a bug: the context dies when the helper returns and the caller's `Body` read fails. Either read the body inside that function, or hand the cancel func to the caller to run after they are done.
code
go · 11 linesctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// shared is a *http.Client with Timeout: 5s.
// This call is bounded at 800ms.
resp, err := shared.Do(req)go deeper
Memorise the three-line shape: derive a context with a timeout, defer cancel, build the request with http.NewRequestWithContext, and pass it to the client you already have.
Explain that both the client field and the request context are enforced and the earlier deadline wins, and be able to describe why the context clock starts at derivation rather than at the call.
Diagnose the defer-cancel-across-a-returned-body bug from its symptom — a cancellation error on the first body read — and be ready to argue for consuming the body inside the helper rather than shipping the cancel func around.
Decide where budgets live across a codebase: a house rule that every outbound request carries a caller-supplied context, with client-level timeouts as backstops that individual calls cannot raise.
## Why the client field is not enough `http.Client.Timeout` is a property of the client object, and a client is meant to be built once and shared — it is safe for concurrent use and its transport caches connections, so a client per call is wasteful and defeats connection reuse. That leaves a tension: one shared client, but calls with genuinely different budgets. A health check to the same host should fail in 200 milliseconds; a report fetch might deserve five seconds. Go resolves this by putting per-call deadlines on the **request**, carried in a `context.Context`. ## The mechanism ```go ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := shared.Do(req) ``` `http.NewRequestWithContext` stores the context on the request; the transport watches it for the whole life of the exchange. When the context is done — deadline passed, or someone called `cancel` — the transport aborts the call and any in-flight read of the body fails. ## Both limits apply If the shared client has `Timeout: 5 * time.Second` and the request context carries 800 milliseconds, the call is bounded at 800 milliseconds. If the context carries 30 seconds, the call is still bounded at 5 seconds. **The request context can tighten the client's ceiling but never raise it**, because the client applies its own limit independently. If a particular dependency legitimately needs longer than the ceiling, that is a signal to give it its own client rather than to argue with the context. ## Where the clock actually starts `context.WithTimeout(parent, d)` computes an absolute deadline of *now plus d* at the moment it is called. This trips people in loops: ```go ctx, cancel := context.WithTimeout(parent, time.Second) defer cancel() for _, url := range urls { // every iteration shares the same one-second deadline } ``` That may be exactly what you want — one second for the whole batch — but it is not "one second each". For per-call budgets, derive a fresh context inside the loop and cancel it each iteration (`defer` inside a loop body accumulates until the function returns, so either wrap the body in a closure or call `cancel()` explicitly). ## The `defer cancel()` trap Calling `cancel` is mandatory: skipping it leaks the timer and the child context until the parent is done, and `go vet`'s `lostcancel` check will flag the obvious cases. But the natural `defer cancel()` is wrong in one specific shape — a helper that returns the response for someone else to read: ```go func fetch(ctx context.Context, c *http.Client, url string) (*http.Response, error) { ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() // fires on return; the caller's Body read now fails req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } return c.Do(req) } ``` The function returns, `cancel` runs, the context is done, and the caller gets an error the first time it reads the body — usually reported as a cancelled request, which sends people hunting for a network fault. Three honest fixes: consume the body inside `fetch` and return bytes or a decoded value; return the `cancel` function alongside the response so the caller defers it; or let the deadline live in the caller's context and take it as a parameter, which is usually the cleanest because the caller is the one who knows the budget. ## Cancellation, not just expiry A context deadline buys more than a timer. Because the same context can be cancelled explicitly, a per-call context lets you abandon in-flight work when it stops being useful: the user hit Ctrl-C, a sibling request in a fan-out already failed, or an outer deadline fired. `Client.Timeout` has no equivalent — there is no way to say "never mind" to a call already in flight through a client field. That is the main reason context deadlines are the default advice in modern Go code and the client field is treated as a backstop. ## Review checklist - Every outbound request built with `NewRequestWithContext`, never `NewRequest` plus a hopeful client field. - A `cancel` reachable on every path, and not deferred across a returned response body. - The context created as close to the call as the budget's meaning allows. - The shared client still shared — deriving a context is free, constructing clients is not.
- Can a request context give a call more time than the client's Timeout?No. Both limits are enforced independently and the earlier deadline ends the call, so a generous context cannot raise a stingy client ceiling. If a dependency genuinely needs longer, give it its own `http.Client` with a larger `Timeout` rather than trying to override from the request side.
- Where does the per-call clock actually start?At `context.WithTimeout`, which computes an absolute deadline immediately. Slow work between deriving the context and calling `Do` spends the budget, and one context reused across a loop is a budget for the whole loop, not for each iteration.
- What does a per-call context buy that a timeout alone does not?Explicit cancellation. The same context can be cancelled because the user interrupted the command or a sibling call in a fan-out already failed, aborting the in-flight request immediately. A client-level `Timeout` can only expire; nothing can say "never mind" to a call already running under it.
saying these in an interview costs you the question
- Constructs a new http.Client per call to change the timeout
- Thinks a longer context deadline overrides Client.Timeout
- Uses http.NewRequest and never attaches a context
- Defers cancel in a helper that returns the response
- Assumes the context clock starts when Do is called