Write an HTTP client retry helper with exponential backoff and jitter. What makes a request safe to retry?
Retry only idempotent requests (GET, HEAD, PUT, DELETE, or POST with an idempotency key), and only on transient failures: network errors, 429, 502/503/504. A request body is an io.Reader consumed on the first attempt, so each retry needs a fresh body from req.GetBody — which http.NewRequest sets automatically for *bytes.Reader, *bytes.Buffer and *strings.Reader. Use full jitter to avoid synchronized retry storms, honor Retry-After, drain/close failed responses so connections are reused, and respect the context.
func doWithRetry(ctx context.Context, c *http.Client, req *http.Request, max int) (*http.Response, error) {
base, maxDelay := 100*time.Millisecond, 5*time.Second
for attempt := 0; ; attempt++ {
r := req.Clone(ctx)
if req.Body != nil && req.Body != http.NoBody {
if req.GetBody == nil {
return nil, errors.New("body not rewindable")
}
body, err := req.GetBody()
if err != nil {
return nil, err
}
r.Body = body
}
resp, err := c.Do(r)
retryable := err != nil && ctx.Err() == nil ||
err == nil && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500)
if !retryable || attempt == max {
return resp, err
}
wait := min(maxDelay, base<<min(attempt, 10)) // cap shift: no overflow
wait = rand.N(wait) // math/rand/v2, full jitter
if resp != nil {
if s, perr := strconv.Atoi(resp.Header.Get("Retry-After")); perr == nil {
wait = time.Duration(s) * time.Second
}
io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
resp.Body.Close()
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait): // Go 1.23+: no timer leak
}
}
}
Follow-ups: put the retry loop in a custom http.RoundTripper so it composes with any client; use a retry budget or circuit breaker so retries don't amplify an outage; an overall context deadline must bound total time across attempts, while Client.Timeout applies per attempt. Note the Transport itself already retries some idempotent requests once on a stale keep-alive connection.
More on Standard Library, HTTP & Systems Design in Go
- Q473How do you unit-test HTTP handlers and HTTP clients with net/http/httptest?
- Q474Implement Server-Sent Events (SSE) in net/http. How do http.ResponseController, flushing and WriteTimeout interact?
- Q476Build a reverse proxy with httputil.ReverseProxy. Why is Rewrite preferred over Director?
- Q477How do you test time-dependent concurrent code deterministically? Explain testing/synctest (Go 1.25).