detached ad8d624

Honor Retry-After and retry transient 429/500/524/529 stream errors

commit
ad8d62420e9a5a87b51f1ed67e8ddd7f509cf3d3
author
patriceckhart · · 2026-07-30 10:05 UTC
parent
a2cd138
signature
unsigned
2 files · +250 -9
M packages/provider/retry.go +101 -7
@@ -4,8 +4,10 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "fmt"
78 "io"
89 "net/http"
10+ "strconv"
911 "strings"
1012 "time"
1113 )
@@ -18,6 +20,12 @@ import (
1820 // noticeably long if the outage is real.
1921 const streamRetryAttempts = 2
2022
23+// maxServerRetryDelay caps how long we are willing to honor a
24+// server-requested Retry-After delay. When a provider asks for a
25+// longer wait (e.g. a quota that resets in hours), the request fails
26+// immediately with an informative error instead of silently blocking.
27+const maxServerRetryDelay = 60 * time.Second
28+
2129 // streamRetryBackoff returns the wait duration before retry attempt n
2230 // (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer
2331 // would feel like the agent froze; anything shorter starts hammering
@@ -39,7 +47,12 @@ func streamRetryBackoff(attempt int) time.Duration {
3947 // Retries fire on:
4048 // - net.Conn dial errors (connection refused, no such host, EOF
4149 // before headers, "upstream connect error", "connection reset")
42-// - HTTP 502/503/504
50+// - HTTP 429/500/502/503/504/524/529, except 429 responses whose
51+// body indicates a terminal quota/billing limit
52+//
53+// When a retryable response carries a Retry-After (or retry-after-ms)
54+// header, the wait before the next attempt uses the server-requested
55+// delay instead of the fixed backoff, capped by maxServerRetryDelay.
4356 //
4457 // The handler is responsible for re-reading the request body each
4558 // attempt; we re-create the request via newReq() because http.Request
@@ -52,12 +65,18 @@ func streamRetryBackoff(attempt int) time.Duration {
5265 // the loop bails out immediately with ctx.Err().
5366 func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) {
5467 var lastErr error
68+ serverDelay := time.Duration(-1) // <0: use the default fixed backoff
5569 for attempt := 0; attempt <= streamRetryAttempts; attempt++ {
5670 if attempt > 0 {
71+ delay := streamRetryBackoff(attempt)
72+ if serverDelay >= 0 {
73+ delay = serverDelay
74+ }
75+ serverDelay = -1
5776 select {
5877 case <-ctx.Done():
5978 return nil, ctx.Err()
60- case <-time.After(streamRetryBackoff(attempt)):
79+ case <-time.After(delay):
6180 }
6281 }
6382 req, err := newReq()
@@ -78,13 +97,28 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (
7897 // at 4 KiB because edge proxies sometimes send pages.
7998 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
8099 resp.Body.Close()
81- lastErr = &transientHTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}
100+ trimmed := strings.TrimSpace(string(body))
101+ if resp.StatusCode == http.StatusTooManyRequests && isTerminalRateLimitBody(trimmed) {
102+ // Subscription/quota/billing limits are not transient
103+ // throttles; retrying only burns time. Surface the
104+ // response immediately so the caller formats the
105+ // normal "http 429: body" error.
106+ return synthesizeResponse(resp.StatusCode, body), nil
107+ }
108+ lastErr = &transientHTTPError{Status: resp.StatusCode, Body: trimmed}
82109 if attempt == streamRetryAttempts {
83110 // Wrap as a real *http.Response shape the caller
84111 // expects so it formats the error identically to a
85112 // non-retried failure.
86113 return synthesizeResponse(resp.StatusCode, body), nil
87114 }
115+ if d, ok := retryAfterDelay(resp.Header); ok {
116+ if d > maxServerRetryDelay {
117+ return nil, fmt.Errorf("server requested %s retry delay (max %s): http %d: %s",
118+ d.Round(time.Second), maxServerRetryDelay, resp.StatusCode, trimmed)
119+ }
120+ serverDelay = d
121+ }
88122 continue
89123 }
90124 return resp, nil
@@ -145,14 +179,74 @@ func isTransientConnectError(err error) bool {
145179 }
146180
147181 // isTransientHTTPStatus reports whether a non-200 status code is
148-// likely transient (502/503/504). 5xx outside this range is surfaced
149-// immediately because it usually indicates a real backend bug.
182+// likely transient. 429 covers rate limits and capacity throttles
183+// (terminal quota/billing 429s are filtered by isTerminalRateLimitBody
184+// in the retry loop). 500 shows up for transient backend failures on
185+// several providers, 524 is Cloudflare's origin timeout, and 529 is
186+// the overloaded status used by Anthropic-style backends.
150187 func isTransientHTTPStatus(code int) bool {
151188 switch code {
152- case http.StatusBadGateway,
189+ case http.StatusTooManyRequests,
190+ http.StatusInternalServerError,
191+ http.StatusBadGateway,
153192 http.StatusServiceUnavailable,
154- http.StatusGatewayTimeout:
193+ http.StatusGatewayTimeout,
194+ 524, // Cloudflare: a timeout occurred
195+ 529: // overloaded
155196 return true
156197 }
157198 return false
158199 }
200+
201+// isTerminalRateLimitBody reports whether a 429 body describes a
202+// subscription, quota, or billing limit rather than a transient
203+// throttle. Retrying those only delays the inevitable error, and on
204+// some backends it can even extend the block.
205+func isTerminalRateLimitBody(body string) bool {
206+ msg := strings.ToLower(body)
207+ for _, needle := range []string{
208+ "gousagelimiterror", "freeusagelimiterror",
209+ "usage limit", "monthly usage limit",
210+ "available balance", "insufficient_quota",
211+ "out of budget", "quota exceeded", "billing",
212+ } {
213+ if strings.Contains(msg, needle) {
214+ return true
215+ }
216+ }
217+ return false
218+}
219+
220+// retryAfterDelay extracts a server-requested retry delay from
221+// response headers. "retry-after-ms" (milliseconds) wins over the
222+// standard "Retry-After", which may hold either delay-seconds or an
223+// HTTP-date. Returns false when no parseable delay is present.
224+func retryAfterDelay(h http.Header) (time.Duration, bool) {
225+ if v := h.Get("retry-after-ms"); v != "" {
226+ if ms, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
227+ if ms < 0 {
228+ ms = 0
229+ }
230+ return time.Duration(ms * float64(time.Millisecond)), true
231+ }
232+ }
233+ v := h.Get("Retry-After")
234+ if v == "" {
235+ return 0, false
236+ }
237+ v = strings.TrimSpace(v)
238+ if secs, err := strconv.ParseFloat(v, 64); err == nil {
239+ if secs < 0 {
240+ secs = 0
241+ }
242+ return time.Duration(secs * float64(time.Second)), true
243+ }
244+ if when, err := http.ParseTime(v); err == nil {
245+ d := time.Until(when)
246+ if d < 0 {
247+ d = 0
248+ }
249+ return d, true
250+ }
251+ return 0, false
252+}
@@ -4,8 +4,10 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
7 "io"8 "io"
8 "net/http"9 "net/http"
10+ "strconv"
9 "strings"11 "strings"
10 "time"12 "time"
11 )13 )
@@ -18,6 +20,12 @@ import (
18 // noticeably long if the outage is real.20 // noticeably long if the outage is real.
19 const streamRetryAttempts = 221 const streamRetryAttempts = 2
20 22
23+// maxServerRetryDelay caps how long we are willing to honor a
24+// server-requested Retry-After delay. When a provider asks for a
25+// longer wait (e.g. a quota that resets in hours), the request fails
26+// immediately with an informative error instead of silently blocking.
27+const maxServerRetryDelay = 60 * time.Second
28+
21 // streamRetryBackoff returns the wait duration before retry attempt n29 // streamRetryBackoff returns the wait duration before retry attempt n
22 // (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer30 // (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer
23 // would feel like the agent froze; anything shorter starts hammering31 // would feel like the agent froze; anything shorter starts hammering
@@ -39,7 +47,12 @@ func streamRetryBackoff(attempt int) time.Duration {
39 // Retries fire on:47 // Retries fire on:
40 // - net.Conn dial errors (connection refused, no such host, EOF48 // - net.Conn dial errors (connection refused, no such host, EOF
41 // before headers, "upstream connect error", "connection reset")49 // before headers, "upstream connect error", "connection reset")
42-// - HTTP 502/503/50450+// - HTTP 429/500/502/503/504/524/529, except 429 responses whose
51+// body indicates a terminal quota/billing limit
52+//
53+// When a retryable response carries a Retry-After (or retry-after-ms)
54+// header, the wait before the next attempt uses the server-requested
55+// delay instead of the fixed backoff, capped by maxServerRetryDelay.
43 //56 //
44 // The handler is responsible for re-reading the request body each57 // The handler is responsible for re-reading the request body each
45 // attempt; we re-create the request via newReq() because http.Request58 // attempt; we re-create the request via newReq() because http.Request
@@ -52,12 +65,18 @@ func streamRetryBackoff(attempt int) time.Duration {
52 // the loop bails out immediately with ctx.Err().65 // the loop bails out immediately with ctx.Err().
53 func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) {66 func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) {
54 var lastErr error67 var lastErr error
68+ serverDelay := time.Duration(-1) // <0: use the default fixed backoff
55 for attempt := 0; attempt <= streamRetryAttempts; attempt++ {69 for attempt := 0; attempt <= streamRetryAttempts; attempt++ {
56 if attempt > 0 {70 if attempt > 0 {
71+ delay := streamRetryBackoff(attempt)
72+ if serverDelay >= 0 {
73+ delay = serverDelay
74+ }
75+ serverDelay = -1
57 select {76 select {
58 case <-ctx.Done():77 case <-ctx.Done():
59 return nil, ctx.Err()78 return nil, ctx.Err()
60- case <-time.After(streamRetryBackoff(attempt)):79+ case <-time.After(delay):
61 }80 }
62 }81 }
63 req, err := newReq()82 req, err := newReq()
@@ -78,13 +97,28 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (
78 // at 4 KiB because edge proxies sometimes send pages.97 // at 4 KiB because edge proxies sometimes send pages.
79 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))98 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
80 resp.Body.Close()99 resp.Body.Close()
81- lastErr = &transientHTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}100+ trimmed := strings.TrimSpace(string(body))
101+ if resp.StatusCode == http.StatusTooManyRequests && isTerminalRateLimitBody(trimmed) {
102+ // Subscription/quota/billing limits are not transient
103+ // throttles; retrying only burns time. Surface the
104+ // response immediately so the caller formats the
105+ // normal "http 429: body" error.
106+ return synthesizeResponse(resp.StatusCode, body), nil
107+ }
108+ lastErr = &transientHTTPError{Status: resp.StatusCode, Body: trimmed}
82 if attempt == streamRetryAttempts {109 if attempt == streamRetryAttempts {
83 // Wrap as a real *http.Response shape the caller110 // Wrap as a real *http.Response shape the caller
84 // expects so it formats the error identically to a111 // expects so it formats the error identically to a
85 // non-retried failure.112 // non-retried failure.
86 return synthesizeResponse(resp.StatusCode, body), nil113 return synthesizeResponse(resp.StatusCode, body), nil
87 }114 }
115+ if d, ok := retryAfterDelay(resp.Header); ok {
116+ if d > maxServerRetryDelay {
117+ return nil, fmt.Errorf("server requested %s retry delay (max %s): http %d: %s",
118+ d.Round(time.Second), maxServerRetryDelay, resp.StatusCode, trimmed)
119+ }
120+ serverDelay = d
121+ }
88 continue122 continue
89 }123 }
90 return resp, nil124 return resp, nil
@@ -145,14 +179,74 @@ func isTransientConnectError(err error) bool {
145 }179 }
146 180
147 // isTransientHTTPStatus reports whether a non-200 status code is181 // isTransientHTTPStatus reports whether a non-200 status code is
148-// likely transient (502/503/504). 5xx outside this range is surfaced182+// likely transient. 429 covers rate limits and capacity throttles
149-// immediately because it usually indicates a real backend bug.183+// (terminal quota/billing 429s are filtered by isTerminalRateLimitBody
184+// in the retry loop). 500 shows up for transient backend failures on
185+// several providers, 524 is Cloudflare's origin timeout, and 529 is
186+// the overloaded status used by Anthropic-style backends.
150 func isTransientHTTPStatus(code int) bool {187 func isTransientHTTPStatus(code int) bool {
151 switch code {188 switch code {
152- case http.StatusBadGateway,189+ case http.StatusTooManyRequests,
190+ http.StatusInternalServerError,
191+ http.StatusBadGateway,
153 http.StatusServiceUnavailable,192 http.StatusServiceUnavailable,
154- http.StatusGatewayTimeout:193+ http.StatusGatewayTimeout,
194+ 524, // Cloudflare: a timeout occurred
195+ 529: // overloaded
155 return true196 return true
156 }197 }
157 return false198 return false
158 }199 }
200+
201+// isTerminalRateLimitBody reports whether a 429 body describes a
202+// subscription, quota, or billing limit rather than a transient
203+// throttle. Retrying those only delays the inevitable error, and on
204+// some backends it can even extend the block.
205+func isTerminalRateLimitBody(body string) bool {
206+ msg := strings.ToLower(body)
207+ for _, needle := range []string{
208+ "gousagelimiterror", "freeusagelimiterror",
209+ "usage limit", "monthly usage limit",
210+ "available balance", "insufficient_quota",
211+ "out of budget", "quota exceeded", "billing",
212+ } {
213+ if strings.Contains(msg, needle) {
214+ return true
215+ }
216+ }
217+ return false
218+}
219+
220+// retryAfterDelay extracts a server-requested retry delay from
221+// response headers. "retry-after-ms" (milliseconds) wins over the
222+// standard "Retry-After", which may hold either delay-seconds or an
223+// HTTP-date. Returns false when no parseable delay is present.
224+func retryAfterDelay(h http.Header) (time.Duration, bool) {
225+ if v := h.Get("retry-after-ms"); v != "" {
226+ if ms, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
227+ if ms < 0 {
228+ ms = 0
229+ }
230+ return time.Duration(ms * float64(time.Millisecond)), true
231+ }
232+ }
233+ v := h.Get("Retry-After")
234+ if v == "" {
235+ return 0, false
236+ }
237+ v = strings.TrimSpace(v)
238+ if secs, err := strconv.ParseFloat(v, 64); err == nil {
239+ if secs < 0 {
240+ secs = 0
241+ }
242+ return time.Duration(secs * float64(time.Second)), true
243+ }
244+ if when, err := http.ParseTime(v); err == nil {
245+ d := time.Until(when)
246+ if d < 0 {
247+ d = 0
248+ }
249+ return d, true
250+ }
251+ return 0, false
252+}
M packages/provider/retry_test.go +149 -2
@@ -7,6 +7,7 @@ import (
77 "strings"
88 "sync/atomic"
99 "testing"
10+ "time"
1011 )
1112
1213 // TestOpenAIStreamRetriesOn503 verifies the OpenAI/Kimi stream client
@@ -104,18 +105,164 @@ func TestOpenAIStreamDoesNotRetryOn400(t *testing.T) {
104105 }
105106
106107 func TestIsTransientHTTPStatus(t *testing.T) {
107- for _, code := range []int{502, 503, 504} {
108+ for _, code := range []int{429, 500, 502, 503, 504, 524, 529} {
108109 if !isTransientHTTPStatus(code) {
109110 t.Errorf("%d should be transient", code)
110111 }
111112 }
112- for _, code := range []int{200, 400, 401, 403, 404, 429, 500, 501} {
113+ for _, code := range []int{200, 400, 401, 403, 404, 501} {
113114 if isTransientHTTPStatus(code) {
114115 t.Errorf("%d should NOT be transient", code)
115116 }
116117 }
117118 }
118119
120+// TestOpenAIStreamRetriesOn429WithRetryAfter verifies that a transient
121+// 429 is retried and that a server-requested Retry-After delay is
122+// honored instead of the fixed backoff.
123+func TestOpenAIStreamRetriesOn429WithRetryAfter(t *testing.T) {
124+ var attempts atomic.Int32
125+ var firstRetryAt atomic.Int64
126+ start := time.Now()
127+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128+ n := attempts.Add(1)
129+ if n == 1 {
130+ w.Header().Set("retry-after-ms", "50")
131+ w.WriteHeader(http.StatusTooManyRequests)
132+ _, _ = w.Write([]byte("Our servers are currently overloaded. Please try again later."))
133+ return
134+ }
135+ firstRetryAt.Store(int64(time.Since(start)))
136+ w.Header().Set("content-type", "text/event-stream")
137+ w.WriteHeader(http.StatusOK)
138+ fl, _ := w.(http.Flusher)
139+ write := func(s string) {
140+ _, _ = w.Write([]byte(s))
141+ if fl != nil {
142+ fl.Flush()
143+ }
144+ }
145+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n")
146+ write("data: [DONE]\n\n")
147+ }))
148+ defer srv.Close()
149+
150+ c := NewOpenAI("x", srv.URL)
151+ evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
152+ if err != nil {
153+ t.Fatalf("stream returned error: %v", err)
154+ }
155+ for range evs {
156+ }
157+ if got := attempts.Load(); got != 2 {
158+ t.Fatalf("attempts=%d want 2", got)
159+ }
160+ if got := time.Duration(firstRetryAt.Load()); got < 50*time.Millisecond {
161+ t.Fatalf("retry fired after %v; want >= 50ms (server-requested delay)", got)
162+ }
163+}
164+
165+// TestOpenAIStreamDoesNotRetryTerminal429 ensures quota/billing 429s
166+// are surfaced immediately without retrying: a usage-limit block will
167+// not clear within the retry window, and hammering the endpoint can
168+// extend the block on some backends.
169+func TestOpenAIStreamDoesNotRetryTerminal429(t *testing.T) {
170+ var attempts atomic.Int32
171+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172+ attempts.Add(1)
173+ w.WriteHeader(http.StatusTooManyRequests)
174+ _, _ = w.Write([]byte(`{"error":{"type":"insufficient_quota","message":"You exceeded your current quota"}}`))
175+ }))
176+ defer srv.Close()
177+
178+ c := NewOpenAI("x", srv.URL)
179+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
180+ if err == nil || !strings.Contains(err.Error(), "429") {
181+ t.Fatalf("want 429 err got %v", err)
182+ }
183+ if got := attempts.Load(); got != 1 {
184+ t.Fatalf("attempts=%d want 1", got)
185+ }
186+}
187+
188+// TestOpenAIStreamRejectsExcessiveRetryAfter verifies that a
189+// server-requested delay beyond maxServerRetryDelay fails immediately
190+// with an informative error instead of blocking silently.
191+func TestOpenAIStreamRejectsExcessiveRetryAfter(t *testing.T) {
192+ var attempts atomic.Int32
193+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
194+ attempts.Add(1)
195+ w.Header().Set("Retry-After", "18000") // 5 hours
196+ w.WriteHeader(http.StatusTooManyRequests)
197+ _, _ = w.Write([]byte("rate limited"))
198+ }))
199+ defer srv.Close()
200+
201+ c := NewOpenAI("x", srv.URL)
202+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
203+ if err == nil || !strings.Contains(err.Error(), "retry delay") {
204+ t.Fatalf("want retry-delay err got %v", err)
205+ }
206+ if got := attempts.Load(); got != 1 {
207+ t.Fatalf("attempts=%d want 1", got)
208+ }
209+}
210+
211+func TestRetryAfterDelay(t *testing.T) {
212+ mk := func(kv ...string) http.Header {
213+ h := http.Header{}
214+ for i := 0; i+1 < len(kv); i += 2 {
215+ h.Set(kv[i], kv[i+1])
216+ }
217+ return h
218+ }
219+ if d, ok := retryAfterDelay(mk("retry-after-ms", "1500")); !ok || d != 1500*time.Millisecond {
220+ t.Errorf("retry-after-ms: got %v %v", d, ok)
221+ }
222+ if d, ok := retryAfterDelay(mk("Retry-After", "2")); !ok || d != 2*time.Second {
223+ t.Errorf("seconds: got %v %v", d, ok)
224+ }
225+ // retry-after-ms takes precedence over Retry-After.
226+ if d, ok := retryAfterDelay(mk("retry-after-ms", "100", "Retry-After", "30")); !ok || d != 100*time.Millisecond {
227+ t.Errorf("precedence: got %v %v", d, ok)
228+ }
229+ // HTTP-date in the past clamps to zero.
230+ if d, ok := retryAfterDelay(mk("Retry-After", "Mon, 02 Jan 2006 15:04:05 GMT")); !ok || d != 0 {
231+ t.Errorf("past date: got %v %v", d, ok)
232+ }
233+ if _, ok := retryAfterDelay(mk()); ok {
234+ t.Error("empty headers should not produce a delay")
235+ }
236+ if _, ok := retryAfterDelay(mk("Retry-After", "soon")); ok {
237+ t.Error("garbage Retry-After should not produce a delay")
238+ }
239+}
240+
241+func TestIsTerminalRateLimitBody(t *testing.T) {
242+ terminal := []string{
243+ `{"error":{"type":"insufficient_quota"}}`,
244+ "Monthly usage limit reached",
245+ "you have hit your usage limit",
246+ "quota exceeded",
247+ "billing hard limit",
248+ }
249+ for _, b := range terminal {
250+ if !isTerminalRateLimitBody(b) {
251+ t.Errorf("%q should be terminal", b)
252+ }
253+ }
254+ transient := []string{
255+ "Our servers are currently overloaded. Please try again later.",
256+ "Rate limit reached, retry shortly",
257+ "",
258+ }
259+ for _, b := range transient {
260+ if isTerminalRateLimitBody(b) {
261+ t.Errorf("%q should NOT be terminal", b)
262+ }
263+ }
264+}
265+
119266 func TestIsTransientConnectError(t *testing.T) {
120267 good := []string{
121268 "read tcp: connection reset by peer",
@@ -7,6 +7,7 @@ import (
7 "strings"7 "strings"
8 "sync/atomic"8 "sync/atomic"
9 "testing"9 "testing"
10+ "time"
10 )11 )
11 12
12 // TestOpenAIStreamRetriesOn503 verifies the OpenAI/Kimi stream client13 // TestOpenAIStreamRetriesOn503 verifies the OpenAI/Kimi stream client
@@ -104,18 +105,164 @@ func TestOpenAIStreamDoesNotRetryOn400(t *testing.T) {
104 }105 }
105 106
106 func TestIsTransientHTTPStatus(t *testing.T) {107 func TestIsTransientHTTPStatus(t *testing.T) {
107- for _, code := range []int{502, 503, 504} {108+ for _, code := range []int{429, 500, 502, 503, 504, 524, 529} {
108 if !isTransientHTTPStatus(code) {109 if !isTransientHTTPStatus(code) {
109 t.Errorf("%d should be transient", code)110 t.Errorf("%d should be transient", code)
110 }111 }
111 }112 }
112- for _, code := range []int{200, 400, 401, 403, 404, 429, 500, 501} {113+ for _, code := range []int{200, 400, 401, 403, 404, 501} {
113 if isTransientHTTPStatus(code) {114 if isTransientHTTPStatus(code) {
114 t.Errorf("%d should NOT be transient", code)115 t.Errorf("%d should NOT be transient", code)
115 }116 }
116 }117 }
117 }118 }
118 119
120+// TestOpenAIStreamRetriesOn429WithRetryAfter verifies that a transient
121+// 429 is retried and that a server-requested Retry-After delay is
122+// honored instead of the fixed backoff.
123+func TestOpenAIStreamRetriesOn429WithRetryAfter(t *testing.T) {
124+ var attempts atomic.Int32
125+ var firstRetryAt atomic.Int64
126+ start := time.Now()
127+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128+ n := attempts.Add(1)
129+ if n == 1 {
130+ w.Header().Set("retry-after-ms", "50")
131+ w.WriteHeader(http.StatusTooManyRequests)
132+ _, _ = w.Write([]byte("Our servers are currently overloaded. Please try again later."))
133+ return
134+ }
135+ firstRetryAt.Store(int64(time.Since(start)))
136+ w.Header().Set("content-type", "text/event-stream")
137+ w.WriteHeader(http.StatusOK)
138+ fl, _ := w.(http.Flusher)
139+ write := func(s string) {
140+ _, _ = w.Write([]byte(s))
141+ if fl != nil {
142+ fl.Flush()
143+ }
144+ }
145+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n")
146+ write("data: [DONE]\n\n")
147+ }))
148+ defer srv.Close()
149+
150+ c := NewOpenAI("x", srv.URL)
151+ evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
152+ if err != nil {
153+ t.Fatalf("stream returned error: %v", err)
154+ }
155+ for range evs {
156+ }
157+ if got := attempts.Load(); got != 2 {
158+ t.Fatalf("attempts=%d want 2", got)
159+ }
160+ if got := time.Duration(firstRetryAt.Load()); got < 50*time.Millisecond {
161+ t.Fatalf("retry fired after %v; want >= 50ms (server-requested delay)", got)
162+ }
163+}
164+
165+// TestOpenAIStreamDoesNotRetryTerminal429 ensures quota/billing 429s
166+// are surfaced immediately without retrying: a usage-limit block will
167+// not clear within the retry window, and hammering the endpoint can
168+// extend the block on some backends.
169+func TestOpenAIStreamDoesNotRetryTerminal429(t *testing.T) {
170+ var attempts atomic.Int32
171+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172+ attempts.Add(1)
173+ w.WriteHeader(http.StatusTooManyRequests)
174+ _, _ = w.Write([]byte(`{"error":{"type":"insufficient_quota","message":"You exceeded your current quota"}}`))
175+ }))
176+ defer srv.Close()
177+
178+ c := NewOpenAI("x", srv.URL)
179+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
180+ if err == nil || !strings.Contains(err.Error(), "429") {
181+ t.Fatalf("want 429 err got %v", err)
182+ }
183+ if got := attempts.Load(); got != 1 {
184+ t.Fatalf("attempts=%d want 1", got)
185+ }
186+}
187+
188+// TestOpenAIStreamRejectsExcessiveRetryAfter verifies that a
189+// server-requested delay beyond maxServerRetryDelay fails immediately
190+// with an informative error instead of blocking silently.
191+func TestOpenAIStreamRejectsExcessiveRetryAfter(t *testing.T) {
192+ var attempts atomic.Int32
193+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
194+ attempts.Add(1)
195+ w.Header().Set("Retry-After", "18000") // 5 hours
196+ w.WriteHeader(http.StatusTooManyRequests)
197+ _, _ = w.Write([]byte("rate limited"))
198+ }))
199+ defer srv.Close()
200+
201+ c := NewOpenAI("x", srv.URL)
202+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
203+ if err == nil || !strings.Contains(err.Error(), "retry delay") {
204+ t.Fatalf("want retry-delay err got %v", err)
205+ }
206+ if got := attempts.Load(); got != 1 {
207+ t.Fatalf("attempts=%d want 1", got)
208+ }
209+}
210+
211+func TestRetryAfterDelay(t *testing.T) {
212+ mk := func(kv ...string) http.Header {
213+ h := http.Header{}
214+ for i := 0; i+1 < len(kv); i += 2 {
215+ h.Set(kv[i], kv[i+1])
216+ }
217+ return h
218+ }
219+ if d, ok := retryAfterDelay(mk("retry-after-ms", "1500")); !ok || d != 1500*time.Millisecond {
220+ t.Errorf("retry-after-ms: got %v %v", d, ok)
221+ }
222+ if d, ok := retryAfterDelay(mk("Retry-After", "2")); !ok || d != 2*time.Second {
223+ t.Errorf("seconds: got %v %v", d, ok)
224+ }
225+ // retry-after-ms takes precedence over Retry-After.
226+ if d, ok := retryAfterDelay(mk("retry-after-ms", "100", "Retry-After", "30")); !ok || d != 100*time.Millisecond {
227+ t.Errorf("precedence: got %v %v", d, ok)
228+ }
229+ // HTTP-date in the past clamps to zero.
230+ if d, ok := retryAfterDelay(mk("Retry-After", "Mon, 02 Jan 2006 15:04:05 GMT")); !ok || d != 0 {
231+ t.Errorf("past date: got %v %v", d, ok)
232+ }
233+ if _, ok := retryAfterDelay(mk()); ok {
234+ t.Error("empty headers should not produce a delay")
235+ }
236+ if _, ok := retryAfterDelay(mk("Retry-After", "soon")); ok {
237+ t.Error("garbage Retry-After should not produce a delay")
238+ }
239+}
240+
241+func TestIsTerminalRateLimitBody(t *testing.T) {
242+ terminal := []string{
243+ `{"error":{"type":"insufficient_quota"}}`,
244+ "Monthly usage limit reached",
245+ "you have hit your usage limit",
246+ "quota exceeded",
247+ "billing hard limit",
248+ }
249+ for _, b := range terminal {
250+ if !isTerminalRateLimitBody(b) {
251+ t.Errorf("%q should be terminal", b)
252+ }
253+ }
254+ transient := []string{
255+ "Our servers are currently overloaded. Please try again later.",
256+ "Rate limit reached, retry shortly",
257+ "",
258+ }
259+ for _, b := range transient {
260+ if isTerminalRateLimitBody(b) {
261+ t.Errorf("%q should NOT be terminal", b)
262+ }
263+ }
264+}
265+
119 func TestIsTransientConnectError(t *testing.T) {266 func TestIsTransientConnectError(t *testing.T) {
120 good := []string{267 good := []string{
121 "read tcp: connection reset by peer",268 "read tcp: connection reset by peer",

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close