Go

Write an HTTP client retry helper with exponential backoff and jitter. What makes a request safe to retry?

Question 475HardGo 1.22 to 1.25

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

All 35 Standard Library, HTTP & Systems Design in Go questions