package retry import ( "context" "errors" "math" "testing" "time" ) var ( ErrInvalidConfig = errors.New("retry: invalid configuration") ErrNilOperation = errors.New("retry: operation is nil") ) // Config controls retry count and exponential delay growth. type Config struct { MaxAttempts int InitialDelay time.Duration MaxDelay time.Duration Multiplier float64 } // Retry calls operation until it succeeds, the attempts are exhausted, or the // context is canceled. The last operation error is returned on exhaustion. func Retry(ctx context.Context, cfg Config, operation func(context.Context) error) error { if operation == nil { return ErrNilOperation } if ctx == nil || cfg.MaxAttempts < 1 || cfg.InitialDelay < 0 || cfg.MaxDelay < cfg.InitialDelay || cfg.Multiplier < 1 || math.IsNaN(cfg.Multiplier) || math.IsInf(cfg.Multiplier, 0) { return ErrInvalidConfig } delay := cfg.InitialDelay for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ { if err := ctx.Err(); err != nil { return err } err := operation(ctx) if err == nil { return nil } if attempt == cfg.MaxAttempts { return err } timer := time.NewTimer(delay) select { case <-timer.C: case <-ctx.Done(): if !timer.Stop() { select { case <-timer.C: default: } } return ctx.Err() } delay = nextDelay(delay, cfg.MaxDelay, cfg.Multiplier) } panic("unreachable") } func nextDelay(current, maximum time.Duration, multiplier float64) time.Duration { if current >= maximum || float64(current)*multiplier >= float64(maximum) { return maximum } return time.Duration(float64(current) * multiplier) } func TestRetryEventuallySucceeds(t *testing.T) { attempts := 0 wantErr := errors.New("temporary failure") err := Retry(context.Background(), Config{ MaxAttempts: 3, InitialDelay: 0, MaxDelay: time.Nanosecond, Multiplier: 2, }, func(context.Context) error { attempts++ if attempts < 3 { return wantErr } return nil }) if err != nil { t.Fatalf("Retry() error = %v", err) } if attempts != 3 { t.Fatalf("attempts = %d, want 3", attempts) } } func TestRetryHonorsCanceledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() called := false err := Retry(ctx, Config{ MaxAttempts: 3, InitialDelay: time.Millisecond, MaxDelay: time.Second, Multiplier: 2, }, func(context.Context) error { called = true return errors.New("failure") }) if !errors.Is(err, context.Canceled) { t.Fatalf("Retry() error = %v, want context.Canceled", err) } if called { t.Fatal("operation was called after context cancellation") } }