http.Get: The Function With No Deadline
The payment webhook handler calls http.Get(ledgerURL + "/entries/" + id) and forwards the result. One day the ledger service accepts connections but never responds. Your handler has no timeout, so its goroutine waits forever; requests pile up behind it; the load balancer keeps sending more. The call that had no deadline becomes the outage — for your service, not the ledger's.
http.Get uses http.DefaultClient, whose Timeout is zero: unlimited. The fix is a client you own, with timeouts layered by phase, because "the request hung" can mean five different hangs:
var client = &http.Client{
Timeout: 10 * time.Second, // ceiling for the WHOLE call: dial -> TLS -> headers -> body
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
},
}
The layering answers different questions. DialContext.Timeout bounds TCP establishment — a black-hole network fails in 3 seconds instead of hanging. TLSHandshakeTimeout catches a peer that accepts TCP but stalls during certificates. ResponseHeaderTimeout catches a server that connects and reads your request but never answers headers — the exact "accepted but silent" failure that motivated all of this. Timeout on the client is the backstop covering everything, including reading the response body, which none of the transport-level knobs see.
Timeouts are only half the control; the other half is cancellation. Derive each call's context from the inbound request so that a client who disconnects does not leave your service fetching on their behalf:
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ledgerURL+"/entries/"+id, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return ErrUpstreamTimeout // your 504, distinct from a 4xx passthrough
}
return fmt.Errorf("call ledger: %w", err)
}
NewRequestWithContext makes the context part of the request, so cancellation propagates through dial, TLS, headers, and body reads. Two deadlines coexist on purpose: the per-call 2 seconds is the budget this operation can afford, while the client's 10-second Timeout is the absolute ceiling for anything using that client. When both exist, the tighter one wins — and the errors still tell you which fired (error identity with errors.Is). What no timeout can fix is an upstream that fails transiently. That is a different problem with its own rules.
Retries Are Only Free for Idempotent Requests
A teammate wraps every downstream call in a retry loop "for resilience." The ledger's POST /charges starts timing out under load, so your service dutifully retries — and every retry that actually succeeded server-side creates a second charge. The resilience feature becomes a duplicate-payment incident, and the retry policy is the culprit.
Retry safety has two axes: was the failure transient (would another attempt plausibly succeed) and is the request safe to repeat (does a second attempt duplicate an effect). Only when both hold should you retry.
func doJSON(ctx context.Context, method, url string, body []byte, out any) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<attempt) * 100 * time.Millisecond // 200ms, 400ms
wait += time.Duration(rand.Int63n(int64(wait / 2))) // jitter
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err() // caller's budget outranks the retry schedule
}
}
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body) // rewindable: safe for repeats
}
req, err := http.NewRequestWithContext(ctx, method, url, rd)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
if !isTransient(err) { // DNS failure, invalid URL: deterministic
return err
}
continue
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
_, _ = io.Copy(io.Discard, resp.Body) // drain so the connection can be reused
resp.Body.Close()
lastErr = fmt.Errorf("upstream %d", resp.StatusCode)
continue
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("upstream %s: %w", resp.Status, ErrClientFault) // 4xx: not transient
}
return json.NewDecoder(resp.Body).Decode(out)
}
return fmt.Errorf("gave up after 3 attempts: %w", lastErr)
}
Every clause encodes a rule. Exponential backoff with jitter stops a thundering herd of clients from stampeding a recovering upstream in lockstep. The select on ctx.Done() means retries never outlive the caller's patience — without it, three attempts times upstream latency blows past the inbound deadline. Rebuilding the body from a []byte each attempt matters because the reader from attempt one is consumed; retrying with a half-read stream sends a truncated request.
What counts as transient: connection resets and timeouts before a response arrived, 429, and 5xx. What never does: 4xx (the request itself is wrong), and — for non-idempotent methods — anything after a response may have been processed. For POST endpoints that must survive retries, the upstream contract needs an idempotency key header so a repeated charge with the same key returns the original result. The client sends the key; the server deduplicates. Neither side alone is enough.
Bounded attempts, jittered backoff, context supremacy, and an honest classification of failures: that is the whole policy. The last piece of the outbound story is what happens to the connections these calls leave behind.
Quiz
1. A client sets Transport.ResponseHeaderTimeout = 5s but no Client.Timeout. A server sends headers in 1s and then trickles a response body forever. What happens?
2. A webhook consumer retries POST /charges on any 5xx. Under a partial outage, customers see duplicate charges. What is the correct policy?
3. A high-throughput service shows constant TCP handshakes to one downstream despite low concurrency spikes. What is the most likely configuration cause?
4. An error path returns early after `resp, err := client.Do(req)` succeeds but a later check fails — the code never touches resp.Body. What accumulates over hours?
Flashcards
Question
When do you use Client.Timeout versus a per-request context deadline?
Click to reveal answer
Answer
Client.Timeout is the absolute ceiling for every call on that client (dial, TLS, headers, body) — a safety net. A per-request context.WithTimeout is the budget for one operation, often derived from the inbound request so client disconnects cancel outbound work; the tighter of the two wins.
Question
Which failure classes are safe to retry automatically?
Click to reveal answer
Answer
Connection-level errors where no response was received, plus 429 and 5xx — and only for idempotent requests (or non-idempotent ones carrying an idempotency key). Never retry 4xx, deterministic failures like DNS errors, or any request whose effect may already have been applied.
Question
Why must a response body be both read to EOF and closed — which order, and why?
Click to reveal answer
Answer
Closing without draining discards the connection instead of returning it to the idle pool; draining without closing leaks it outright. Read (or io.Copy to Discard) then Close — that sequence lets the transport reuse the connection for the next request to the same host.
Question
Why not just set http.DefaultClient.Timeout for the whole process?
Click to reveal answer
Answer
DefaultClient is shared global state: every library that did not bring its own client inherits your timeout, and your calls inherit whatever some dependency changed. Own an explicit client — configuration at wiring time, defaults left alone, same discipline as middleware closures.
Question
What do exponential backoff with jitter actually protect against?
Click to reveal answer
Answer
Exponential growth stops clients from hammering a recovering upstream at a fixed rate; jitter spreads the retries so a fleet does not re-stampede in lockstep. Combined with an attempt cap and the caller's context, it bounds both upstream pressure and your own latency budget.
Revision Notes
Key Takeaways
- 1. The default client has no timeout; layered Transport timeouts diagnose which phase hangs while Client.Timeout backstops everything
- 2. Retry loops need both a transient-failure test and an idempotency test — plus context supremacy so retries never outlive the caller
- 3. Backoff without jitter synchronizes failing clients; jitter is what actually prevents the stampede
- 4. Connection reuse — one client, drained-and-closed bodies, MaxIdleConnsPerHost above 2 — is where most outbound latency wins come from
- 5. Global defaults are shared state; own your client the way middleware owns its config
Interview Tips
- • "How do you call another service in Go?" — layered timeouts table, NewRequestWithContext from the inbound request, then the error mapping: 504 for deadline, pass through upstream 4xx
- • "When is a retry safe?" — the two-axis answer (transient + idempotent), name the idempotency-key pattern for POSTs, and mention jitter
- • "Your service's p99 to a dependency spiked after traffic grew — diagnose" — MaxIdleConnsPerHost=2 connection churn; ask about body-close discipline too
- • "Client.Timeout vs context timeout" — absolute ceiling vs per-call budget; show errors.Is(err, context.DeadlineExceeded) mapping to a 504
Cheat Sheet
HTTP Clients, Timeouts, and Retries
Which knob bounds which phase:
| Knob | Bounds |
|---|---|
DialContext.Timeout |
TCP connect |
TLSHandshakeTimeout |
certificate exchange |
ResponseHeaderTimeout |
request sent -> first response header |
Client.Timeout |
everything, including body read (the backstop) |
context.WithTimeout |
this call's budget; cancels all phases |
Retry rule: retry = transient failure AND safe to repeat. Connection errors before a response, 429, 5xx; never 4xx; non-idempotent POSTs only with an idempotency key. Backoff: base * 2^attempt + jitter, capped attempts, select on ctx.Done().
Pool code:
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100, MaxIdleConnsPerHost: 20, // default is 2!
IdleConnTimeout: 90 * time.Second,
},
}
// every response: io.Copy(io.Discard, body); body.Close()
Decision rules: one shared client per configuration; derive per-call ctx from the inbound request; classify upstream errors into client-fault (pass through), timeout (your 504), and transient (retryable).
Continue Learning
Before You Start
- No prerequisites listed.