HTTP Client with Retries, Backoff, and Retry-After Support
HTTP client helper that wraps requests with retry logic, exponential backoff with jitter, and Retry-After support.
main.go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"strconv"
"time"
)
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
RetryAfterEnabled bool
RetryStatusCodes map[int]bool
}
func defaultRetryConfig() RetryConfig {
return RetryConfig{
MaxAttempts: 5,
BaseDelay: 250 * time.Millisecond,
MaxDelay: 5 * time.Second,
RetryAfterEnabled: true,
RetryStatusCodes: map[int]bool{
http.StatusTooManyRequests: true,
http.StatusBadGateway: true,
http.StatusServiceUnavailable: true,
http.StatusGatewayTimeout: true,
http.StatusInternalServerError: true,
},
}
}
func backoffDelay(rng *rand.Rand, attempt int, base, max time.Duration) time.Duration {
if attempt <= 1 {
return base
}
d := base << (attempt - 1)
if d > max {
d = max
}
jitter := time.Duration(rng.Int63n(int64(d/3) + 1))
return d - (d / 6) + jitter
}
func parseRetryAfterSeconds(resp *http.Response) (time.Duration, bool) {
if resp == nil {
return 0, false
}
v := resp.Header.Get("Retry-After")
if v == "" {
return 0, false
}
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
return time.Duration(secs) * time.Second, true
}
if t, err := http.ParseTime(v); err == nil {
d := time.Until(t)
if d < 0 {
return 0, false
}
return d, true
}
return 0, false
}
func doWithRetry(ctx context.Context, client *http.Client, req *http.Request, cfg RetryConfig) (*http.Response, error) {
if cfg.MaxAttempts < 1 {
return nil, fmt.Errorf("MaxAttempts must be >= 1")
}
if req.Body != nil {
return nil, fmt.Errorf("this helper requires req.Body to be nil or replayable; use req.GetBody or buffer the body")
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
var lastErr error
for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
r := req.Clone(ctx)
resp, err := client.Do(r)
if err == nil && (resp.StatusCode < 500 && !cfg.RetryStatusCodes[resp.StatusCode]) {
return resp, nil
}
if err != nil {
lastErr = err
} else {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("retryable HTTP status: %s", resp.Status)
}
if attempt == cfg.MaxAttempts {
break
}
delay := backoffDelay(rng, attempt, cfg.BaseDelay, cfg.MaxDelay)
if cfg.RetryAfterEnabled {
if ra, ok := parseRetryAfterSeconds(resp); ok && ra > delay {
delay = ra
}
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
if lastErr == nil {
lastErr = errors.New("request failed")
}
return nil, lastErr
}
func main() {
client := &http.Client{
Timeout: 10 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
if err != nil {
log.Fatalf("build request: %v", err)
}
resp, err := doWithRetry(ctx, client, req, defaultRetryConfig())
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
log.Printf("status=%s body_prefix=%q", resp.Status, string(body))
}How It Works
HTTP client helper that wraps requests with retry logic, exponential backoff with jitter, and Retry-After support.
Defines a retryable status and error set, issues requests with context, checks responses, reads Retry-After headers to delay, applies exponential backoff with jitter between attempts, and stops on success or context cancellation.
Key Concepts
- 1Backoff with jitter avoids thundering herd retries.
- 2Understands HTTP semantics for retryable versus non-retryable responses.
- 3Respects Retry-After headers to align with server guidance.
When to Use This Pattern
- Calling flaky upstream APIs robustly.
- Resilient webhooks or outgoing notifications.
- Data fetchers that must survive transient network blips.
Best Practices
- Set an overall deadline to avoid unbounded retry loops.
- Do not retry non-idempotent requests unless it is safe.
- Log attempts and final errors for observability.
Go Version1.18+
Difficultyintermediate
Production ReadyYes
Lines of Code149