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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "net/http" | |
| 10 | + | "strconv" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "time" | |
| 11 | 13 | ) | |
| @@ -18,6 +20,12 @@ import ( | |||
| 18 | 20 | // noticeably long if the outage is real. | |
| 19 | 21 | 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 | 29 | // streamRetryBackoff returns the wait duration before retry attempt n | |
| 22 | 30 | // (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer | |
| 23 | 31 | // would feel like the agent froze; anything shorter starts hammering | |
| @@ -39,7 +47,12 @@ func streamRetryBackoff(attempt int) time.Duration { | |||
| 39 | 47 | // Retries fire on: | |
| 40 | 48 | // - net.Conn dial errors (connection refused, no such host, EOF | |
| 41 | 49 | // 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. | |
| 43 | 56 | // | |
| 44 | 57 | // The handler is responsible for re-reading the request body each | |
| 45 | 58 | // attempt; we re-create the request via newReq() because http.Request | |
| @@ -52,12 +65,18 @@ func streamRetryBackoff(attempt int) time.Duration { | |||
| 52 | 65 | // the loop bails out immediately with ctx.Err(). | |
| 53 | 66 | func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) { | |
| 54 | 67 | var lastErr error | |
| 68 | + | serverDelay := time.Duration(-1) // <0: use the default fixed backoff | |
| 55 | 69 | for attempt := 0; attempt <= streamRetryAttempts; attempt++ { | |
| 56 | 70 | if attempt > 0 { | |
| 71 | + | delay := streamRetryBackoff(attempt) | |
| 72 | + | if serverDelay >= 0 { | |
| 73 | + | delay = serverDelay | |
| 74 | + | } | |
| 75 | + | serverDelay = -1 | |
| 57 | 76 | select { | |
| 58 | 77 | case <-ctx.Done(): | |
| 59 | 78 | return nil, ctx.Err() | |
| 60 | - | case <-time.After(streamRetryBackoff(attempt)): | |
| 79 | + | case <-time.After(delay): | |
| 61 | 80 | } | |
| 62 | 81 | } | |
| 63 | 82 | req, err := newReq() | |
| @@ -78,13 +97,28 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() ( | |||
| 78 | 97 | // at 4 KiB because edge proxies sometimes send pages. | |
| 79 | 98 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) | |
| 80 | 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 | 109 | if attempt == streamRetryAttempts { | |
| 83 | 110 | // Wrap as a real *http.Response shape the caller | |
| 84 | 111 | // expects so it formats the error identically to a | |
| 85 | 112 | // non-retried failure. | |
| 86 | 113 | 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 | 122 | continue | |
| 89 | 123 | } | |
| 90 | 124 | return resp, nil | |
| @@ -145,14 +179,74 @@ func isTransientConnectError(err error) bool { | |||
| 145 | 179 | } | |
| 146 | 180 | ||
| 147 | 181 | // 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. | |
| 150 | 187 | func isTransientHTTPStatus(code int) bool { | |
| 151 | 188 | switch code { | |
| 152 | - | case http.StatusBadGateway, | |
| 189 | + | case http.StatusTooManyRequests, | |
| 190 | + | http.StatusInternalServerError, | |
| 191 | + | http.StatusBadGateway, | |
| 153 | 192 | http.StatusServiceUnavailable, | |
| 154 | - | http.StatusGatewayTimeout: | |
| 193 | + | http.StatusGatewayTimeout, | |
| 194 | + | 524, // Cloudflare: a timeout occurred | |
| 195 | + | 529: // overloaded | |
| 155 | 196 | return true | |
| 156 | 197 | } | |
| 157 | 198 | 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 | + | } | |
| @@ -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 = 2 | 21 | 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 n | 29 | // streamRetryBackoff returns the wait duration before retry attempt n | ||
| 22 | // (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer | 30 | // (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer | ||
| 23 | // would feel like the agent froze; anything shorter starts hammering | 31 | // 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, EOF | 48 | // - 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/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. | |||
| 43 | // | 56 | // | ||
| 44 | // The handler is responsible for re-reading the request body each | 57 | // The handler is responsible for re-reading the request body each | ||
| 45 | // attempt; we re-create the request via newReq() because http.Request | 58 | // 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 error | 67 | 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 caller | 110 | // Wrap as a real *http.Response shape the caller | ||
| 84 | // expects so it formats the error identically to a | 111 | // expects so it formats the error identically to a | ||
| 85 | // non-retried failure. | 112 | // non-retried failure. | ||
| 86 | return synthesizeResponse(resp.StatusCode, body), nil | 113 | 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 | continue | 122 | continue | ||
| 89 | } | 123 | } | ||
| 90 | return resp, nil | 124 | 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 is | 181 | // isTransientHTTPStatus reports whether a non-200 status code is | ||
| 148 | - | // likely transient (502/503/504). 5xx outside this range is surfaced | 182 | + | // 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 true | 196 | return true | ||
| 156 | } | 197 | } | ||
| 157 | return false | 198 | 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 ( | |||
| 7 | 7 | "strings" | |
| 8 | 8 | "sync/atomic" | |
| 9 | 9 | "testing" | |
| 10 | + | "time" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // TestOpenAIStreamRetriesOn503 verifies the OpenAI/Kimi stream client | |
| @@ -104,18 +105,164 @@ func TestOpenAIStreamDoesNotRetryOn400(t *testing.T) { | |||
| 104 | 105 | } | |
| 105 | 106 | ||
| 106 | 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 | 109 | if !isTransientHTTPStatus(code) { | |
| 109 | 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 | 114 | if isTransientHTTPStatus(code) { | |
| 114 | 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 | 266 | func TestIsTransientConnectError(t *testing.T) { | |
| 120 | 267 | good := []string{ | |
| 121 | 268 | "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 client | 13 | // 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", | ||