返回 DeepSeek-Reasonix
retry_test.go
根目录 / internal / provider / retry_test.go
1 package provider
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net"
9 "net/http"
10 "strings"
11 "sync"
12 "syscall"
13 "testing"
14 "time"
15 )
16
17 type rtFunc func(*http.Request) (*http.Response, error)
18
19 func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
20
21 func statusResp(status int, hdr map[string]string) *http.Response {
22 h := http.Header{}
23 for k, v := range hdr {
24 h.Set(k, v)
25 }
26 return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("body")), Header: h}
27 }
28
29 func newDummyReq(ctx context.Context) (*http.Request, error) {
30 return http.NewRequestWithContext(ctx, http.MethodPost, "http://x/y", nil)
31 }
32
33 func TestRetryableStatus(t *testing.T) {
34 for _, s := range []int{408, 429, 500, 502, 503, 504, 529, 599} {
35 if !RetryableStatus(s) {
36 t.Errorf("status %d should be retryable", s)
37 }
38 }
39 for _, s := range []int{200, 400, 401, 402, 403, 404, 422} {
40 if RetryableStatus(s) {
41 t.Errorf("status %d should not be retryable", s)
42 }
43 }
44 }
45
46 func TestTransientErr(t *testing.T) {
47 if transientErr(nil) {
48 t.Error("nil should not be transient")
49 }
50 if transientErr(context.Canceled) || transientErr(context.DeadlineExceeded) {
51 t.Error("ctx cancel/deadline should not be transient")
52 }
53 if !transientErr(errors.New("connection reset")) {
54 t.Error("network-ish error should be transient")
55 }
56 }
57
58 func TestIsConnReset(t *testing.T) {
59 if IsConnReset(nil) {
60 t.Error("nil is not a conn reset")
61 }
62 if IsConnReset(context.Canceled) || IsConnReset(context.DeadlineExceeded) {
63 t.Error("ctx cancel/deadline must not look like a recoverable reset")
64 }
65 if IsConnReset(errors.New("decode stream: invalid character")) {
66 t.Error("a plain protocol error must not be treated as a conn reset")
67 }
68 for _, err := range []error{
69 io.ErrUnexpectedEOF,
70 &net.OpError{Op: "read", Err: syscall.ECONNRESET},
71 fmt.Errorf("read stream: %w", &net.OpError{Op: "read", Err: errors.New("wsarecv: forcibly closed")}),
72 } {
73 if !IsConnReset(err) {
74 t.Errorf("want conn reset for %v", err)
75 }
76 }
77 }
78
79 func TestBackoffDelay(t *testing.T) {
80 if d := backoffDelay(1, 0); d < 500*time.Millisecond || d >= 750*time.Millisecond {
81 t.Errorf("attempt 1 base delay = %v, want [500ms,750ms)", d)
82 }
83 if d := backoffDelay(20, 0); d > maxBackoff+250*time.Millisecond {
84 t.Errorf("delay %v exceeds cap+jitter", d)
85 }
86 if d := backoffDelay(5, 3*time.Second); d != 3*time.Second {
87 t.Errorf("Retry-After should win: %v", d)
88 }
89 if d := backoffDelay(1, 45*time.Second); d != 45*time.Second {
90 t.Errorf("Retry-After beyond the backoff cap should still be honored: %v", d)
91 }
92 if d := backoffDelay(1, time.Hour); d != maxRetryAfter {
93 t.Errorf("Retry-After should be capped to %v, got %v", maxRetryAfter, d)
94 }
95 }
96
97 func TestParseRetryAfterAcceptsHTTPDate(t *testing.T) {
98 resp := &http.Response{Header: http.Header{}}
99 resp.Header.Set("Retry-After", time.Now().Add(30*time.Second).UTC().Format(http.TimeFormat))
100 if d := parseRetryAfter(resp); d < 25*time.Second || d > 31*time.Second {
101 t.Errorf("http-date Retry-After = %v, want ~30s", d)
102 }
103
104 resp.Header.Set("Retry-After", time.Now().Add(-time.Minute).UTC().Format(http.TimeFormat))
105 if d := parseRetryAfter(resp); d != 0 {
106 t.Errorf("elapsed http-date Retry-After = %v, want 0", d)
107 }
108 }
109
110 func TestSendWithRetryFailsFastOnClientErrors(t *testing.T) {
111 for _, status := range []int{400, 402, 422} {
112 calls := 0
113 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
114 calls++
115 return statusResp(status, nil), nil
116 })}
117 _, err := SendWithRetry(context.Background(), cl, SendOptions{Provider: "p", KeyEnv: "KEY"}, newDummyReq)
118 if calls != 1 {
119 t.Errorf("status %d retried (%d calls), should fail fast", status, calls)
120 }
121 var apiErr *APIError
122 if !errors.As(err, &apiErr) || apiErr.Status != status {
123 t.Errorf("status %d: want *APIError with Status=%d, got %v", status, status, err)
124 }
125 }
126 }
127
128 func TestSendWithRetryPreservesProviderTraceID(t *testing.T) {
129 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
130 return statusResp(422, map[string]string{"trace_id": "minimax-trace-123"}), nil
131 })}
132 _, err := SendWithRetry(context.Background(), cl, SendOptions{Provider: "minimax-cn-api"}, newDummyReq)
133 var apiErr *APIError
134 if !errors.As(err, &apiErr) {
135 t.Fatalf("want *APIError, got %T: %v", err, err)
136 }
137 if apiErr.TraceID != "minimax-trace-123" {
138 t.Fatalf("TraceID = %q, want minimax-trace-123", apiErr.TraceID)
139 }
140 }
141
142 func TestSendWithRetryAuthError(t *testing.T) {
143 calls := 0
144 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
145 calls++
146 return statusResp(401, nil), nil
147 })}
148 _, err := SendWithRetry(context.Background(), cl, SendOptions{Provider: "deepseek", KeyEnv: "DEEPSEEK_API_KEY", KeyPresent: true}, newDummyReq)
149 if calls != 1 {
150 t.Errorf("401 retried (%d calls), should fail fast for a never-authed key", calls)
151 }
152 var authErr *AuthError
153 if !errors.As(err, &authErr) || authErr.KeyEnv != "DEEPSEEK_API_KEY" {
154 t.Errorf("want *AuthError naming the key env, got %v", err)
155 }
156 if authErr != nil && authErr.Body != "body" {
157 t.Errorf("AuthError should carry the response body, got %q", authErr.Body)
158 }
159 }
160
161 func TestSendWithRetryRetriesTransientAuthForKnownKey(t *testing.T) {
162 calls := 0
163 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
164 calls++
165 if calls <= 2 {
166 return statusResp(401, nil), nil
167 }
168 return statusResp(200, nil), nil
169 })}
170 resp, err := SendWithRetry(context.Background(), cl,
171 SendOptions{Provider: "mimo", KeyEnv: "MIMO_API_KEY", KeyPresent: true, RetryAuth: true}, newDummyReq)
172 if err != nil {
173 t.Fatalf("a previously-good key should recover from a transient 401: %v", err)
174 }
175 if resp.StatusCode != 200 || calls != 3 {
176 t.Fatalf("status=%d calls=%d, want 200 after 3 calls", resp.StatusCode, calls)
177 }
178 }
179
180 func TestSendWithRetryAuthGivesUpAfterMaxAuthRetries(t *testing.T) {
181 calls := 0
182 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
183 calls++
184 return statusResp(401, nil), nil
185 })}
186 _, err := SendWithRetry(context.Background(), cl,
187 SendOptions{Provider: "mimo", KeyEnv: "MIMO_API_KEY", KeyPresent: true, RetryAuth: true}, newDummyReq)
188 if calls != 1+maxAuthRetries {
189 t.Errorf("persistent 401 made %d calls, want %d (initial + maxAuthRetries)", calls, 1+maxAuthRetries)
190 }
191 var authErr *AuthError
192 if !errors.As(err, &authErr) || !authErr.HasKey {
193 t.Fatalf("want *AuthError with HasKey=true, got %v", err)
194 }
195 }
196
197 // stallingBody sends headers' worth of promise and then never delivers: Read
198 // blocks until Close, mimicking a half-open 502/524 gateway that stalls after
199 // the status line. Close is what the errorBodyReadTimeout timer fires.
200 type stallingBody struct {
201 closeOnce sync.Once
202 closed chan struct{}
203 }
204
205 func newStallingBody() *stallingBody { return &stallingBody{closed: make(chan struct{})} }
206
207 func (b *stallingBody) Read(p []byte) (int, error) {
208 <-b.closed
209 return 0, errors.New("body closed")
210 }
211
212 func (b *stallingBody) Close() error {
213 b.closeOnce.Do(func() { close(b.closed) })
214 return nil
215 }
216
217 // TestSendWithRetryUnblocksStalledErrorBody locks in the #6607 freeze fix: a
218 // retryable status whose body never arrives must not wedge the retry loop —
219 // the deadline closes the body, the attempt is retried, and the eventual OK
220 // response is returned. Without the timer in readErrorBody this test hangs on
221 // the first 502 body and fails via the watchdog below.
222 func TestSendWithRetryUnblocksStalledErrorBody(t *testing.T) {
223 prev := errorBodyReadTimeout
224 errorBodyReadTimeout = 50 * time.Millisecond
225 defer func() { errorBodyReadTimeout = prev }()
226
227 calls := 0
228 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
229 calls++
230 if calls == 1 {
231 return &http.Response{StatusCode: 502, Body: newStallingBody(), Header: http.Header{}}, nil
232 }
233 return statusResp(200, nil), nil
234 })}
235
236 type result struct {
237 resp *http.Response
238 err error
239 }
240 done := make(chan result, 1)
241 go func() {
242 resp, err := SendWithRetry(context.Background(), cl, SendOptions{Provider: "p", KeyEnv: "KEY"}, newDummyReq)
243 done <- result{resp, err}
244 }()
245
246 select {
247 case r := <-done:
248 if r.err != nil {
249 t.Fatalf("should recover after the stalled 502: %v", r.err)
250 }
251 if r.resp.StatusCode != 200 || calls != 2 {
252 t.Fatalf("status=%d calls=%d, want 200 after 2 calls", r.resp.StatusCode, calls)
253 }
254 case <-time.After(5 * time.Second):
255 t.Fatal("SendWithRetry wedged on a stalled error body — read deadline did not fire")
256 }
257 }
258
259 func TestSendWithRetryRecoversAndNotifies(t *testing.T) {
260 calls := 0
261 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
262 calls++
263 if calls == 1 {
264 return statusResp(503, nil), nil
265 }
266 return statusResp(200, nil), nil
267 })}
268 var infos []RetryInfo
269 ctx := WithRequestAttemptCounter(context.Background())
270 ctx = WithRetryNotify(ctx, func(i RetryInfo) { infos = append(infos, i) })
271
272 resp, err := SendWithRetry(ctx, cl, SendOptions{Provider: "p", KeyEnv: "KEY"}, newDummyReq)
273 if err != nil {
274 t.Fatalf("should recover after one retry: %v", err)
275 }
276 if resp.StatusCode != 200 || calls != 2 {
277 t.Fatalf("status=%d calls=%d, want 200 after 2 calls", resp.StatusCode, calls)
278 }
279 if len(infos) != 1 || infos[0].Attempt != 1 || infos[0].Max != MaxRetries {
280 t.Fatalf("retry notify = %#v, want one Attempt 1/%d", infos, MaxRetries)
281 }
282 if got := RequestAttemptCount(ctx); got != 2 {
283 t.Fatalf("request attempt count = %d, want 2", got)
284 }
285 }
286
287 func TestRequestAttemptCountSurvivesRetriesThenTerminalFailure(t *testing.T) {
288 calls := 0
289 cl := &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) {
290 calls++
291 if calls < 3 {
292 return statusResp(http.StatusServiceUnavailable, nil), nil
293 }
294 return statusResp(http.StatusBadRequest, nil), nil
295 })}
296 ctx := WithRequestAttemptCounter(context.Background())
297 providerCtx := WithRequestAttemptCounter(ctx)
298
299 if _, err := SendWithRetry(providerCtx, cl, SendOptions{Provider: "p"}, newDummyReq); err == nil {
300 t.Fatal("expected terminal provider error")
301 }
302 if got := RequestAttemptCount(ctx); got != 3 {
303 t.Fatalf("request attempt count = %d, want 3", got)
304 }
305 usage := UsageWithRequestAttemptCount(ctx, nil)
306 if usage == nil || usage.TotalTokens != 0 || usage.RequestCount != 3 {
307 t.Fatalf("failed request usage = %+v, want tokens=0 requests=3", usage)
308 }
309 }
310
310 lines GO