1package typesafe23import (4 "context"5 "errors"6 "io"7 "math"8 "net/http"9 "strings"10 "testing"11 "testing/synctest"12 "time"13)1415type roundTripFunc func(*http.Request) (*http.Response, error)1617func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }1819func mockResponse(status int, header http.Header) *http.Response {20 return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(`{"models":[]}`))}21}2223func retryClient(t *testing.T, transport roundTripFunc, policy RetryPolicy) *Client {24 t.Helper()25 client, err := NewClient(WithAPIKey("key"), WithBaseURL("https://test.invalid"),26 WithHTTPClient(&http.Client{Transport: transport}), WithRetryPolicy(policy))27 if err != nil {28 t.Fatal(err)29 }30 return client31}3233func TestRetryStatusAndCount(t *testing.T) {34 for _, status := range []int{400, 401, 403, 404, 408, 422, 429, 500, 503, 529, 599} {35 t.Run(http.StatusText(status), func(t *testing.T) {36 calls := 037 policy := DefaultRetryPolicy()38 policy.InitialDelay, policy.MaxDelay = 0, 039 client := retryClient(t, func(request *http.Request) (*http.Response, error) {40 calls++41 if calls > 1 && request.Header.Get("X-TypeSafe-Retry-Count") == "" {42 t.Error("retry identity header missing")43 }44 return mockResponse(status, make(http.Header)), nil45 }, policy)46 _, err := client.ListModels(context.Background())47 if _, ok := errors.AsType[*APIError](err); !ok {48 t.Fatalf("expected APIError, got %v", err)49 }50 want := 151 if status == 408 || status == 429 || status >= 500 {52 want = 353 }54 if calls != want {55 t.Fatalf("attempts=%d, want %d", calls, want)56 }57 })58 }59}6061func TestRetryAfterAndCancellation(t *testing.T) {62 synctest.Test(t, func(t *testing.T) {63 calls := 064 client := retryClient(t, func(request *http.Request) (*http.Response, error) {65 calls++66 if calls == 1 {67 return mockResponse(429, http.Header{"Retry-After": {"3"}}), nil68 }69 return mockResponse(200, make(http.Header)), nil70 }, DefaultRetryPolicy())71 start := time.Now()72 if _, err := client.ListModels(t.Context()); err != nil {73 t.Fatal(err)74 }75 if calls != 2 || time.Since(start) != 3*time.Second {76 t.Fatalf("calls=%d delay=%s", calls, time.Since(start))77 }78 calls = 079 ctx, cancel := context.WithTimeout(t.Context(), time.Second)80 defer cancel()81 if _, err := client.ListModels(ctx); !errors.Is(err, context.DeadlineExceeded) || calls != 1 {82 t.Fatalf("backoff ignored cancellation: calls=%d err=%v", calls, err)83 }84 })85}8687func TestConnectionRetries(t *testing.T) {88 for _, enabled := range []bool{false, true} {89 calls := 090 policy := DefaultRetryPolicy()91 policy.RetryConnectionErrors = enabled92 policy.InitialDelay, policy.MaxDelay = 0, 093 client := retryClient(t, func(request *http.Request) (*http.Response, error) {94 calls++95 if calls == 1 {96 return nil, io.ErrUnexpectedEOF97 }98 return mockResponse(200, make(http.Header)), nil99 }, policy)100 _, err := client.ListModels(context.Background())101 if enabled && (err != nil || calls != 2) {102 t.Fatalf("expected recovery: calls=%d err=%v", calls, err)103 }104 if !enabled && (!errors.Is(err, io.ErrUnexpectedEOF) || calls != 1) {105 t.Fatalf("unexpected retry: calls=%d err=%v", calls, err)106 }107 }108}109110func TestRetryTimeoutPolicy(t *testing.T) {111 synctest.Test(t, func(t *testing.T) {112 for _, enabled := range []bool{false, true} {113 calls := 0114 policy := DefaultRetryPolicy()115 policy.RetryTimeouts = enabled116 policy.InitialDelay, policy.MaxDelay = 0, 0117 client := retryClient(t, func(request *http.Request) (*http.Response, error) {118 calls++119 <-request.Context().Done()120 return nil, request.Context().Err()121 }, policy)122 _, err := client.ListModels(t.Context())123 want := 1124 if enabled {125 want = 3126 }127 if !errors.Is(err, context.DeadlineExceeded) || calls != want {128 t.Fatalf("calls=%d want=%d err=%v", calls, want, err)129 }130 }131 })132}133134func TestRetryDelay(t *testing.T) {135 policy := DefaultRetryPolicy()136 policy.Jitter = 0137 tests := []struct {138 name string139 headers http.Header140 attempt int141 want time.Duration142 }{143 {"initial", nil, 0, 500 * time.Millisecond},144 {"exponential", nil, 2, 2 * time.Second},145 {"capped", nil, 10000, 5 * time.Second},146 {"milliseconds", http.Header{"Retry-After-Ms": {"1250"}, "Retry-After": {"10"}}, 0, 1250 * time.Millisecond},147 {"zero", http.Header{"Retry-After": {"0"}}, 0, 0},148 {"invalid", http.Header{"Retry-After": {"garbage"}}, 0, 500 * time.Millisecond},149 {"too large", http.Header{"Retry-After": {"9999999999999999999999"}}, 0, 500 * time.Millisecond},150 {"negative", http.Header{"Retry-After": {"-1"}}, 0, 500 * time.Millisecond},151 }152 for _, test := range tests {153 t.Run(test.name, func(t *testing.T) {154 if got := retryDelay(policy, test.attempt, test.headers); got != test.want {155 t.Fatalf("delay=%s want=%s", got, test.want)156 }157 })158 }159}160161func TestRetryDelayDurationBoundaries(t *testing.T) {162 policy := RetryPolicy{InitialDelay: time.Duration(math.MaxInt64), MaxDelay: time.Duration(math.MaxInt64)}163 if got := retryDelay(policy, 0, nil); got != policy.MaxDelay {164 t.Fatalf("maximum duration overflowed: %s", got)165 }166 policy.InitialDelay, policy.MaxDelay = 0, time.Nanosecond167 if got := retryDelay(policy, 1, nil); got != 0 {168 t.Fatalf("zero backoff became positive: %s", got)169 }170}