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