skip to content

An httptest.NewServer handler sleeps 2s, the SDK sets http.Client.Timeout to 1s and the caller passes a 500ms context — which deadline surfaces, and how do you prove it?

level: seniorimportance: should knowfreq 42%

answer

  1. three clocks are running at once
  2. nobody negotiates; the earliest wins
  3. Timeout() is true for both layers
  4. ask the context whether it expired
  5. Close waits for the handler to return

basics

~10 s

The shortest one wins: the caller's 500ms context deadline ends the request first. Prove it by checking ctx.Err() is context.DeadlineExceeded, since a Client.Timeout error also reports a timeout and leaves the context unexpired.

solid answer

~50 s

All three limits run at once and the earliest expiry ends the request, so the caller's 500ms `context.Context` fires; the 1s `http.Client.Timeout` and the handler's 2s sleep never matter. `Do` returns a `*url.Error`, and both timeout layers report `Timeout() == true`, so `net.Error.Timeout()` cannot separate them. What separates them is `ctx.Err()`: it is `context.DeadlineExceeded` only when the caller's context expired, and `nil` when `Client.Timeout` fired — whose error text names `Client.Timeout` explicitly. Two other things belong in this test. Make the handler `select` on `r.Context().Done()` instead of sleeping unconditionally, because the server cancels a request context when the client hangs up, and `srv.Close()` blocks until outstanding requests finish — a blind `time.Sleep` makes the deferred `Close` wait the full 2s. And record what the test server actually received, so you can assert the SDK stopped rather than silently retrying.

code

go · 30 lines
go
var mu sync.Mutex
var got []string

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	got = append(got, r.Method+" "+r.URL.Path)
	mu.Unlock()
	select {
	case <-time.After(2 * time.Second):
		io.WriteString(w, `{"status":"succeeded"}`)
	case <-r.Context().Done(): // caller hung up: do not block srv.Close
	}
}))
defer srv.Close()

sdk := payments.New(srv.URL, &http.Client{Timeout: time.Second})

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

_, err := sdk.Charge(ctx, "ch_1")
if !errors.Is(err, context.DeadlineExceeded) || ctx.Err() == nil {
	t.Fatalf("want the caller's context deadline, got %v (ctx.Err=%v)", err, ctx.Err())
}

mu.Lock()
defer mu.Unlock()
if len(got) != 1 {
	t.Errorf("requests = %v, want exactly one (no retry after cancellation)", got)
}

go deeper

for a junior

Recall that a context deadline, a client timeout and the server's own latency all apply at once, and the shortest one ends the request. Attach the context with http.NewRequestWithContext.

for a middle

Explain the mechanics of each layer — what span the client timeout covers, that both layers report a timeout error, and that the request's context on the server side is cancelled when the client disconnects.

for a senior

Diagnose it: name ctx.Err as the check that separates the layers, make the test handler cancellable so Close does not stall, and assert on the requests the server actually received to catch a retry that ignores cancellation.

for a principal

Own the convention for outbound clients across a codebase: context first parameter, caller sets the policy, library timeout only as a backstop, and errors classifiable with errors.Is so callers can distinguish giving up from a real rejection.

## Three clocks, one request When a client SDK wraps a slow third-party API, a single outbound call can be under three independent deadlines at the same time: 1. **The caller's `context.Context`** — `context.WithTimeout(ctx, 500*time.Millisecond)`, attached to the request with `http.NewRequestWithContext`. 2. **The SDK's `http.Client.Timeout`** — 1s here. It covers the *whole* exchange: dial, TLS handshake, sending the request, reading response headers, and reading the body. `net/http` implements it by setting a deadline on the request internally, so it composes with the context rather than replacing it. 3. **The remote side's own latency** — here, the test server's handler sleeping 2s. Nothing negotiates between them. They all run, and the first to expire ends the request. 500ms < 1s < 2s, so the caller's context wins, and the SDK's own timeout is never exercised by this test. That is the trap in the scenario: an engineer who wrote the test to prove the SDK's 1s timeout works has instead proved the context plumbing works, and the SDK's timeout could be broken or absent without the test noticing. ## Telling the layers apart `client.Do(req)` returns a `*url.Error` wrapping the underlying failure. Both timeout layers produce an error for which `net.Error.Timeout()` is true, so that predicate is useless for distinguishing them. Useful signals, in order of reliability: - **`ctx.Err()`** — after the call, this is `context.DeadlineExceeded` if and only if the caller's context expired. If `Client.Timeout` fired instead, the context is still live and `ctx.Err()` is `nil`. This is the check to write. - **The error text** — a `Client.Timeout` failure names itself, with wording such as `Client.Timeout exceeded while awaiting headers`. Fine for a log line or a failure message; too brittle to assert on. - **Elapsed time** — measuring roughly 500ms rather than roughly 1s corroborates it, but a timing assertion in CI is a flake generator unless you give it generous slack. So the honest test is: set the deadlines far enough apart that the intended one is unambiguous, assert `errors.Is(err, context.DeadlineExceeded)` **together with** `ctx.Err() != nil` when you mean the context, and write a *separate* test with a long-lived context to exercise the SDK's own timeout. ## Make the handler cancellable, or `Close` will punish you ```go srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case <-time.After(2 * time.Second): io.WriteString(w, `{"status":"succeeded"}`) case <-r.Context().Done(): // client hung up; return promptly } })) defer srv.Close() ``` Two facts make this shape necessary. For an incoming server request, the request's context is **cancelled when the client's connection closes** or when `ServeHTTP` returns. And `(*httptest.Server).Close()` **blocks until all outstanding requests have completed**. A handler that calls `time.Sleep(2 * time.Second)` unconditionally therefore keeps sleeping after the client has given up, and your deferred `Close` stalls the test for the remainder — multiplied by every such test in the package. Selecting on `r.Context().Done()` also makes the handler a faithful double: a real server abandons work when the caller disconnects. ## What the test should actually assert An error is the least interesting part. For a wrapper around a payments API, the questions a reviewer cares about are: - **Did the SDK stop, or did it retry?** Record every request the test server received — append the method and path to a slice guarded by a `sync.Mutex`, since handler invocations run on their own goroutines — and assert the count. A retry loop that ignores context cancellation is the classic defect here, and it is invisible if you only assert on the returned error. - **Is the error classifiable by the caller?** The SDK should return something for which `errors.Is(err, context.DeadlineExceeded)` holds, rather than a bare string, so callers can distinguish "we gave up" from "the card was declined". - **Was the connection cleaned up?** If the SDK returned a response on a happier path, it must read and close the body; otherwise the connection is not reused. When an assertion fails and the response is a surprise, `httputil.DumpResponse(res, true)` prints status line, headers and body in one go — much faster to read than four log statements, and it shows the headers the vendor's shape actually carried. ## Choosing where the deadline belongs The production guidance behind the scenario: a library's own `http.Client.Timeout` is a **backstop**, not the policy. The caller's `context.Context` is the policy, because only the caller knows how long the surrounding operation may take. Set the client timeout generously above any deadline a sane caller would pass, and make sure every SDK method takes a `context.Context` as its first parameter and passes it through with `http.NewRequestWithContext`. An SDK that builds requests without the context silently ignores caller cancellation, and a test with three deadlines is exactly how you catch that.

  • Why does a handler that calls time.Sleep unconditionally make the test slow even after the client gives up?
    `(*httptest.Server).Close` blocks until outstanding requests have completed, so the deferred Close waits out the full sleep. Selecting on `r.Context().Done()` fixes it: the server cancels an incoming request's context when the client's connection closes, so the handler returns promptly — and it models a real server, which abandons work on disconnect.
  • How would you write a test that actually exercises the SDK's own http.Client.Timeout?
    Give the caller a context with no deadline, or one far longer than the client timeout, and make the handler slower than the client timeout. Then assert the error names the client timeout layer and that `ctx.Err()` is nil, proving the context did not do the work.
  • Beyond the returned error, what should this test assert?
    How many requests the test server received. Record them in the handler under a mutex, since handlers run on separate goroutines, and assert the count is one. A retry loop that ignores context cancellation is the common defect, and asserting only on the error cannot see it.
  • Should a client library set an http.Client.Timeout at all if callers pass a context?
    Yes, as a backstop against a caller who passes context.Background, but set it well above any deadline a reasonable caller would use. The context is the policy because only the caller knows the surrounding operation's budget; the client timeout only stops an unbounded hang.

saying these in an interview costs you the question

  • Claiming the SDK's client timeout wins because it is closer to the request
  • Using net.Error Timeout to tell the two deadline layers apart
  • Sleeping unconditionally in a test handler and blocking Close
  • Asserting only that an error came back, never the request count
  • Building requests without the caller's context so cancellation is ignored