package transport import ( "context" "errors" "testing" "time" ) func TestDefaultRetryPolicy(t *testing.T) { p := DefaultRetryPolicy() if p.Initial != RetryInitial { t.Errorf("Initial = %v, want %v", p.Initial, RetryInitial) } if p.Max != RetryMax { t.Errorf("Max = %v, want %v", p.Max, RetryMax) } if p.MaxAttempts != RetryMaxAttempts { t.Errorf("MaxAttempts = %d, want %d", p.MaxAttempts, RetryMaxAttempts) } } func TestRetrySucceedsFirstAttempt(t *testing.T) { calls := 0 got, err := Do(context.Background(), DefaultRetryPolicy(), func(_ context.Context, attempt int) (string, bool, error) { calls++ if attempt != 1 { t.Errorf("attempt = %d, want 1", attempt) } return "ok", true, nil }) if err != nil { t.Fatalf("Do: %v", err) } if got != "ok" { t.Errorf("got = %q, want ok", got) } if calls != 1 { t.Errorf("calls = %d, want 1", calls) } } func TestRetryIdempotentVerbRetries(t *testing.T) { calls := 0 _, err := Do(context.Background(), DefaultRetryPolicy(), func(_ context.Context, _ int) (string, bool, error) { calls++ return "", true, errors.New("connection refused") }) if err == nil { t.Fatal("expected error after exhausting attempts") } if calls != RetryMaxAttempts { t.Errorf("calls = %d, want %d", calls, RetryMaxAttempts) } } func TestRetryWithIdempotencyKeyRetries(t *testing.T) { calls := 0 ctx := WithIdempotencyKey(context.Background(), "key-1") _, err := Do(ctx, DefaultRetryPolicy(), func(_ context.Context, _ int) (string, bool, error) { calls++ return "", false, errors.New("i/o timeout") }) if err == nil { t.Fatal("expected error after exhausting attempts") } if calls != RetryMaxAttempts { t.Errorf("calls = %d, want %d (idempotency key enables retry)", calls, RetryMaxAttempts) } } func TestRetryMaxAttemptsReached(t *testing.T) { p := RetryPolicy{Initial: time.Millisecond, Max: 5 * time.Millisecond, MaxAttempts: 3} calls := 0 _, err := Do(context.Background(), p, func(_ context.Context, _ int) (string, bool, error) { calls++ return "", true, errors.New("EOF") }) if err == nil { t.Fatal("expected error") } if !IsTransient(err) { t.Errorf("expected transient error, got %v", err) } if calls != 3 { t.Errorf("calls = %d, want 3", calls) } } func TestRetryZeroMaxAttemptsDefaults(t *testing.T) { calls := 0 p := RetryPolicy{} _, err := Do(context.Background(), p, func(_ context.Context, _ int) (string, bool, error) { calls++ return "ok", true, nil }) if err != nil { t.Fatalf("Do: %v", err) } if calls != 1 { t.Errorf("calls = %d, want 1", calls) } } func TestRetryNonTransientIdempotentRetries(t *testing.T) { calls := 0 _, err := Do(context.Background(), DefaultRetryPolicy(), func(_ context.Context, _ int) (string, bool, error) { calls++ return "", true, errors.New("invalid spec") }) if err == nil { t.Fatal("expected error") } if calls != RetryMaxAttempts { t.Errorf("calls = %d, want %d (non-transient idempotent still retries)", calls, RetryMaxAttempts) } } func TestRetryTransientNonIdempotentNoKeyBails(t *testing.T) { calls := 0 _, err := Do(context.Background(), DefaultRetryPolicy(), func(_ context.Context, _ int) (string, bool, error) { calls++ return "", false, errors.New("connection refused") }) if err == nil { t.Fatal("expected error") } if calls != 1 { t.Errorf("calls = %d, want 1 (transient+non-idempotent+no key = bail)", calls) } } func TestRetryContextCancelledMidBackoff(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) p := RetryPolicy{Initial: 100 * time.Millisecond, Max: time.Second, MaxAttempts: 5} calls := 0 go func() { time.Sleep(20 * time.Millisecond) cancel() }() _, err := Do(ctx, p, func(_ context.Context, _ int) (string, bool, error) { calls++ return "", true, errors.New("connection refused") }) if err == nil { t.Fatal("expected error") } if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } } func TestBackoffGrowsExponentially(t *testing.T) { initial := 10 * time.Millisecond max := 1 * time.Second d1 := backoff(initial, max, 1) d2 := backoff(initial, max, 2) d3 := backoff(initial, max, 3) if d1 < 0 { t.Errorf("backoff(1) = %v, want >= 0", d1) } if d2 < d1 { t.Errorf("backoff(2)=%v < backoff(1)=%v (should grow)", d2, d1) } if d3 < d2 { t.Errorf("backoff(3)=%v < backoff(2)=%v (should grow)", d3, d2) } } func TestBackoffCapsAtMax(t *testing.T) { initial := 100 * time.Millisecond max := 200 * time.Millisecond d := backoff(initial, max, 10) if d > max+max/2 { t.Errorf("backoff(10) = %v, want <= ~max=%v", d, max) } } func TestContains(t *testing.T) { cases := []struct { s, sub string want bool }{ {"hello world", "world", true}, {"hello", "xyz", false}, {"hello", "", true}, {"", "", true}, {"abc", "abcd", false}, } for _, c := range cases { if got := contains(c.s, c.sub); got != c.want { t.Errorf("contains(%q, %q) = %v, want %v", c.s, c.sub, got, c.want) } } }