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