Retry Jitter Exponential Backoff

Retry transient failures with capped exponential backoff plus jitter, classifying errors by retryability and giving up with context.

GoResilience

Code

snippet
package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"
)

var (
	errTransient = errors.New("transient upstream failure")
	errPermanent = errors.New("invalid argument")
)

func classify(err error) bool {
	if errors.Is(err, errPermanent) {
		return false // 永久错误不值得重试
	}
	return errors.Is(err, errTransient) || isNetworkErr(err)
}

func isNetworkErr(err error) bool {
	var netErr interface{ Timeout() bool }
	return errors.As(err, &netErr)
}

func backoff(attempt int, base, cap time.Duration) time.Duration {
	exp := base << attempt // base * 2^attempt
	if exp > cap || exp <= 0 {
		exp = cap
	}
	jitter := time.Duration(rand.Int63n(int64(exp) / 2))
	return exp - jitter // 全抖动:避免多客户端同步重试
}

func withRetry(ctx context.Context, fn func() error, maxAttempts int) error {
	var lastErr error

	for attempt := 0; attempt < maxAttempts; attempt++ {
		if err := ctx.Err(); err != nil {
			return fmt.Errorf("context done: %w", err)
		}
		if err := fn(); err == nil {
			return nil
		} else {
			lastErr = err
			if !classify(err) {
				return fmt.Errorf("permanent failure: %w", err)
			}
		}
		if attempt < maxAttempts-1 {
			wait := backoff(attempt, 100*time.Millisecond, 5*time.Second)
			select {
			case <-time.After(wait):
			case <-ctx.Done():
				return ctx.Err()
			}
		}
	}
	return fmt.Errorf("gave up after %d attempts: %w", maxAttempts, lastErr)
}

func main() {
	calls := 0
	err := withRetry(context.Background(), func() error {
		calls++
		if calls < 3 {
			return errTransient
		}
		return nil
	}, 5)
	fmt.Println(err, "calls:", calls)
}