package main import ( "context" "errors" "fmt" "testing" "time" ) func TestSucceedsAfterTransientFailures(t *testing.T) { calls := 0 err := Retry(context.Background(), 5, time.Millisecond, func(ctx context.Context) error { calls++ if calls < 3 { return fmt.Errorf("flaky: %w", ErrRetryable) } return nil }) if err != nil { t.Fatalf("want success, got %v", err) } if calls != 3 { t.Fatalf("want 3 calls, got %d", calls) } } func TestNonRetryableStopsImmediately(t *testing.T) { fatal := errors.New("permission denied") calls := 0 err := Retry(context.Background(), 5, time.Millisecond, func(ctx context.Context) error { calls++ return fatal }) if calls != 1 { t.Fatalf("non-retryable error must stop retries: %d calls", calls) } if !errors.Is(err, fatal) { t.Fatalf("returned error should match/wrap the fatal error, got %v", err) } } func TestExhaustionWrapsLastError(t *testing.T) { calls := 0 err := Retry(context.Background(), 3, time.Millisecond, func(ctx context.Context) error { calls++ return fmt.Errorf("boom %d: %w", calls, ErrRetryable) }) if calls != 3 { t.Fatalf("want 3 attempts, got %d", calls) } if err == nil || !errors.Is(err, ErrRetryable) { t.Fatalf("exhaustion error must wrap the last attempt error, got %v", err) } } func TestContextCancelsSleep(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(30 * time.Millisecond) cancel() }() start := time.Now() err := Retry(ctx, 10, 10*time.Second, func(ctx context.Context) error { return fmt.Errorf("always: %w", ErrRetryable) }) elapsed := time.Since(start) if elapsed > time.Second { t.Fatalf("Retry ignored ctx during backoff sleep: took %v (time.Sleep instead of select?)", elapsed) } if err == nil || !errors.Is(err, context.Canceled) { t.Fatalf("want error matching context.Canceled, got %v", err) } } func TestBackoffActuallyWaits(t *testing.T) { start := time.Now() _ = Retry(context.Background(), 3, 40*time.Millisecond, func(ctx context.Context) error { return fmt.Errorf("nope: %w", ErrRetryable) }) // Full jitter lower bound is 0, so we can't assert a minimum reliably — // but the loop must finish in bounded time: < base*(2^0+2^1+2^2) + slack. if elapsed := time.Since(start); elapsed > 500*time.Millisecond { t.Fatalf("backoff far too long (sleeping after last attempt?): %v", elapsed) } }