mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
333 lines
9.1 KiB
Go
333 lines
9.1 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- IsRetryableError ---
|
|
|
|
func TestIsRetryableError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"http_429_rate_limit", &HTTPError{Status: 429}, true},
|
|
{"http_500_server_error", &HTTPError{Status: 500}, true},
|
|
{"http_502_bad_gateway", &HTTPError{Status: 502}, true},
|
|
{"http_503_unavailable", &HTTPError{Status: 503}, true},
|
|
{"http_504_timeout", &HTTPError{Status: 504}, true},
|
|
{"http_400_bad_request", &HTTPError{Status: 400}, false},
|
|
{"http_401_unauthorized", &HTTPError{Status: 401}, false},
|
|
{"http_403_forbidden", &HTTPError{Status: 403}, false},
|
|
{"http_404_not_found", &HTTPError{Status: 404}, false},
|
|
{"connection_reset", errors.New("connection reset by peer"), true},
|
|
{"broken_pipe", errors.New("write: broken pipe"), true},
|
|
{"eof", errors.New("unexpected EOF"), true},
|
|
{"timeout_string", errors.New("i/o timeout"), true},
|
|
{"codex_retry_guidance", errors.New("codex: response failed: An error occurred while processing your request. You can retry your request, or contact us through our help center."), true},
|
|
{"codex_rate_limit_wording", errors.New("codex: response failed: rate limit exceeded"), true},
|
|
{"generic_error", errors.New("something went wrong"), false},
|
|
{"wrapped_retryable", fmt.Errorf("provider: %w", &HTTPError{Status: 429}), true},
|
|
{"wrapped_non_retryable", fmt.Errorf("provider: %w", &HTTPError{Status: 400}), false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := IsRetryableError(tt.err)
|
|
if got != tt.want {
|
|
t.Fatalf("IsRetryableError(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- computeDelay ---
|
|
|
|
func TestComputeDelay_ExponentialBackoff(t *testing.T) {
|
|
cfg := RetryConfig{
|
|
MinDelay: 100 * time.Millisecond,
|
|
MaxDelay: 10 * time.Second,
|
|
Jitter: 0, // no jitter for deterministic test
|
|
}
|
|
err := &HTTPError{Status: 500}
|
|
|
|
// attempt 1: 100ms * 2^0 = 100ms
|
|
d1 := computeDelay(cfg, 1, err)
|
|
if d1 != 100*time.Millisecond {
|
|
t.Fatalf("attempt 1: got %v, want 100ms", d1)
|
|
}
|
|
|
|
// attempt 2: 100ms * 2^1 = 200ms
|
|
d2 := computeDelay(cfg, 2, err)
|
|
if d2 != 200*time.Millisecond {
|
|
t.Fatalf("attempt 2: got %v, want 200ms", d2)
|
|
}
|
|
|
|
// attempt 3: 100ms * 2^2 = 400ms
|
|
d3 := computeDelay(cfg, 3, err)
|
|
if d3 != 400*time.Millisecond {
|
|
t.Fatalf("attempt 3: got %v, want 400ms", d3)
|
|
}
|
|
|
|
// attempt 4: 100ms * 2^3 = 800ms
|
|
d4 := computeDelay(cfg, 4, err)
|
|
if d4 != 800*time.Millisecond {
|
|
t.Fatalf("attempt 4: got %v, want 800ms", d4)
|
|
}
|
|
}
|
|
|
|
func TestComputeDelay_CappedAtMaxDelay(t *testing.T) {
|
|
cfg := RetryConfig{
|
|
MinDelay: 1 * time.Second,
|
|
MaxDelay: 5 * time.Second,
|
|
Jitter: 0,
|
|
}
|
|
err := &HTTPError{Status: 500}
|
|
|
|
// attempt 10: 1s * 2^9 = 512s → capped at 5s
|
|
d := computeDelay(cfg, 10, err)
|
|
if d != 5*time.Second {
|
|
t.Fatalf("attempt 10: got %v, want 5s (capped)", d)
|
|
}
|
|
}
|
|
|
|
func TestComputeDelay_JitterRange(t *testing.T) {
|
|
cfg := RetryConfig{
|
|
MinDelay: 1 * time.Second,
|
|
MaxDelay: 30 * time.Second,
|
|
Jitter: 0.25, // ±25%
|
|
}
|
|
err := &HTTPError{Status: 500}
|
|
|
|
// attempt 1: base = 1s, jitter ±25% → [750ms, 1250ms]
|
|
min := 750 * time.Millisecond
|
|
max := 1250 * time.Millisecond
|
|
|
|
for range 100 {
|
|
d := computeDelay(cfg, 1, err)
|
|
if d < min || d > max {
|
|
t.Fatalf("jitter out of range: got %v, want [%v, %v]", d, min, max)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComputeDelay_NeverNegative(t *testing.T) {
|
|
cfg := RetryConfig{
|
|
MinDelay: 10 * time.Millisecond,
|
|
MaxDelay: 100 * time.Millisecond,
|
|
Jitter: 0.9, // extreme jitter
|
|
}
|
|
err := &HTTPError{Status: 500}
|
|
|
|
for range 200 {
|
|
d := computeDelay(cfg, 1, err)
|
|
if d < 0 {
|
|
t.Fatalf("negative delay: %v", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComputeDelay_RetryAfterOverride(t *testing.T) {
|
|
cfg := RetryConfig{
|
|
MinDelay: 100 * time.Millisecond,
|
|
MaxDelay: 30 * time.Second,
|
|
Jitter: 0.1,
|
|
}
|
|
// HTTPError with RetryAfter should override computed delay
|
|
err := &HTTPError{Status: 429, RetryAfter: 42 * time.Second}
|
|
|
|
d := computeDelay(cfg, 1, err)
|
|
if d != 42*time.Second {
|
|
t.Fatalf("expected Retry-After override: got %v, want 42s", d)
|
|
}
|
|
}
|
|
|
|
// --- ParseRetryAfter ---
|
|
|
|
func TestParseRetryAfter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
want time.Duration
|
|
}{
|
|
{"empty", "", 0},
|
|
{"integer_seconds", "30", 30 * time.Second},
|
|
{"zero", "0", 0},
|
|
{"negative_int", "-5", -5 * time.Second}, // strconv.Atoi succeeds → returns negative duration (caller should clamp)
|
|
{"non_numeric", "abc", 0}, // neither int nor date
|
|
{"float", "1.5", 0}, // not a valid int
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ParseRetryAfter(tt.value)
|
|
if got != tt.want {
|
|
t.Fatalf("ParseRetryAfter(%q) = %v, want %v", tt.value, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseRetryAfter_RFC1123(t *testing.T) {
|
|
// Use a future date so time.Until() returns positive
|
|
future := time.Now().Add(60 * time.Second).UTC().Format(time.RFC1123)
|
|
got := ParseRetryAfter(future)
|
|
// Should be roughly 60s (allow ±5s tolerance for test execution time)
|
|
if got < 55*time.Second || got > 65*time.Second {
|
|
t.Fatalf("RFC1123 parse: got %v, want ~60s", got)
|
|
}
|
|
}
|
|
|
|
func TestParseRetryAfter_PastDate(t *testing.T) {
|
|
past := time.Now().Add(-60 * time.Second).UTC().Format(time.RFC1123)
|
|
got := ParseRetryAfter(past)
|
|
if got != 0 {
|
|
t.Fatalf("past date should return 0, got %v", got)
|
|
}
|
|
}
|
|
|
|
// --- RetryDo ---
|
|
|
|
func TestRetryDo_SuccessOnFirstAttempt(t *testing.T) {
|
|
cfg := RetryConfig{Attempts: 3, MinDelay: time.Millisecond}
|
|
var calls int
|
|
result, err := RetryDo(context.Background(), cfg, func() (string, error) {
|
|
calls++
|
|
return "ok", nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result != "ok" {
|
|
t.Fatalf("got %q, want %q", result, "ok")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("expected 1 call, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryDo_SuccessAfterRetries(t *testing.T) {
|
|
cfg := RetryConfig{Attempts: 3, MinDelay: time.Millisecond, MaxDelay: 10 * time.Millisecond}
|
|
var calls int
|
|
result, err := RetryDo(context.Background(), cfg, func() (string, error) {
|
|
calls++
|
|
if calls < 3 {
|
|
return "", &HTTPError{Status: 500, Body: "server error"}
|
|
}
|
|
return "recovered", nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result != "recovered" {
|
|
t.Fatalf("got %q, want %q", result, "recovered")
|
|
}
|
|
if calls != 3 {
|
|
t.Fatalf("expected 3 calls, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryDo_NonRetryableError_NoRetry(t *testing.T) {
|
|
cfg := RetryConfig{Attempts: 5, MinDelay: time.Millisecond}
|
|
var calls int
|
|
_, err := RetryDo(context.Background(), cfg, func() (string, error) {
|
|
calls++
|
|
return "", &HTTPError{Status: 400, Body: "bad request"}
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("non-retryable error should not retry: got %d calls, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryDo_MaxAttemptsExhausted(t *testing.T) {
|
|
cfg := RetryConfig{Attempts: 3, MinDelay: time.Millisecond, MaxDelay: 10 * time.Millisecond}
|
|
var calls int
|
|
_, err := RetryDo(context.Background(), cfg, func() (string, error) {
|
|
calls++
|
|
return "", &HTTPError{Status: 503, Body: "unavailable"}
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error after exhausting retries")
|
|
}
|
|
if calls != 3 {
|
|
t.Fatalf("expected 3 attempts, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryDo_ContextCancellation(t *testing.T) {
|
|
cfg := RetryConfig{Attempts: 10, MinDelay: 5 * time.Second, MaxDelay: 5 * time.Second}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
start := time.Now()
|
|
_, err := RetryDo(ctx, cfg, func() (string, error) {
|
|
return "", &HTTPError{Status: 500}
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
|
}
|
|
// Should have been cancelled quickly, not waited for 5s backoff
|
|
if elapsed > 2*time.Second {
|
|
t.Fatalf("context cancellation took too long: %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestRetryDo_HookCalledOnRetry(t *testing.T) {
|
|
cfg := RetryConfig{Attempts: 3, MinDelay: time.Millisecond, MaxDelay: 10 * time.Millisecond}
|
|
|
|
var hookCalls atomic.Int32
|
|
ctx := WithRetryHook(context.Background(), func(attempt, maxAttempts int, err error) {
|
|
hookCalls.Add(1)
|
|
})
|
|
|
|
RetryDo(ctx, cfg, func() (string, error) {
|
|
return "", &HTTPError{Status: 500}
|
|
})
|
|
|
|
// Hook called before each retry (not the first attempt, not the last failure)
|
|
// With 3 attempts: attempt 1 fails → hook → attempt 2 fails → hook → attempt 3 fails → done
|
|
if got := hookCalls.Load(); got != 2 {
|
|
t.Fatalf("expected 2 hook calls, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRetryDo_ZeroAttempts_DefaultsToOne(t *testing.T) {
|
|
cfg := RetryConfig{Attempts: 0}
|
|
var calls int
|
|
_, err := RetryDo(context.Background(), cfg, func() (string, error) {
|
|
calls++
|
|
return "", &HTTPError{Status: 500}
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("zero attempts should default to 1: got %d calls", calls)
|
|
}
|
|
}
|
|
|
|
// --- HTTPError ---
|
|
|
|
func TestHTTPError_ErrorString(t *testing.T) {
|
|
err := &HTTPError{Status: 429, Body: "rate limited"}
|
|
got := err.Error()
|
|
want := "HTTP 429: rate limited"
|
|
if got != want {
|
|
t.Fatalf("got %q, want %q", got, want)
|
|
}
|
|
}
|