Skip to content

Commit 1ae5ef5

Browse files
jkyberneeesclaude
andcommitted
fix(llm): honor Retry-After on rate-limited responses
The retry loop used a fixed 1/2/4s exponential backoff and ignored the server's Retry-After header, so a real rate limit (Retry-After: 20-60s) burned all three retries in ~7s and failed the turn even though the server said exactly when to come back. Parse Retry-After (integer seconds or HTTP-date) on retryable statuses and use it for the next wait, capped at 60s so a pathological value can't wedge a turn (ctx still breaks the wait). Tests: parseRetryAfter unit cases (seconds, blank, garbage, zero, cap) and a 429+Retry-After call that retries and succeeds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent dabc1cf commit 1ae5ef5

2 files changed

Lines changed: 101 additions & 3 deletions

File tree

internal/llm/client.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"strconv"
1112
"strings"
1213
"time"
1314

@@ -299,17 +300,19 @@ func (c *Client) postChatWithRetry(ctx context.Context, reqBytes []byte) ([]byte
299300

300301
const maxRetries = 3
301302
var lastErr error
303+
var wait time.Duration // how long to sleep before the next attempt
302304

303305
for attempt := 0; attempt <= maxRetries; attempt++ {
304306
if attempt > 0 {
305-
// Exponential backoff: 1s, 2s, 4s.
306-
backoff := time.Duration(1<<(attempt-1)) * time.Second
307307
select {
308308
case <-ctx.Done():
309309
return nil, ctx.Err()
310-
case <-time.After(backoff):
310+
case <-time.After(wait):
311311
}
312312
}
313+
// Default backoff for the next attempt if this one fails: 1s, 2s, 4s.
314+
// A Retry-After header on a 429/503 overrides it below.
315+
wait = time.Duration(1<<attempt) * time.Second
313316

314317
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBytes))
315318
if err != nil {
@@ -329,6 +332,7 @@ func (c *Client) postChatWithRetry(ctx context.Context, reqBytes []byte) ([]byte
329332
}
330333

331334
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
335+
retryAfter := resp.Header.Get("Retry-After")
332336
resp.Body.Close()
333337
if err != nil {
334338
lastErr = fmt.Errorf("llm: read response: %w", err)
@@ -346,6 +350,13 @@ func (c *Client) postChatWithRetry(ctx context.Context, reqBytes []byte) ([]byte
346350
lastErr = fmt.Errorf("llm: %s (status %d)", resp.Status, resp.StatusCode)
347351
}
348352
if isRetryableHTTPStatus(resp.StatusCode) {
353+
// Honor the server's Retry-After (seconds or HTTP-date) when it
354+
// asks us to wait longer than our default backoff — otherwise a
355+
// rate-limited turn burns all three retries in ~7s and fails
356+
// even though the server told us exactly when to come back.
357+
if ra := parseRetryAfter(retryAfter); ra > 0 {
358+
wait = ra
359+
}
349360
continue
350361
}
351362
return nil, lastErr
@@ -357,6 +368,40 @@ func (c *Client) postChatWithRetry(ctx context.Context, reqBytes []byte) ([]byte
357368
return nil, fmt.Errorf("llm: retry exhausted (%d attempts): %w", maxRetries+1, lastErr)
358369
}
359370

371+
// maxRetryAfter caps how long we'll honor a server's Retry-After. A pathological
372+
// or hostile value (e.g. "Retry-After: 86400") must not wedge a turn for hours;
373+
// ctx cancellation can still break the wait sooner.
374+
const maxRetryAfter = 60 * time.Second
375+
376+
// parseRetryAfter interprets an HTTP Retry-After header, which is either an
377+
// integer number of seconds or an HTTP-date. Returns 0 when absent or
378+
// unparseable (callers then fall back to exponential backoff). The result is
379+
// capped at maxRetryAfter.
380+
func parseRetryAfter(v string) time.Duration {
381+
v = strings.TrimSpace(v)
382+
if v == "" {
383+
return 0
384+
}
385+
var d time.Duration
386+
if secs, err := strconv.Atoi(v); err == nil {
387+
if secs <= 0 {
388+
return 0
389+
}
390+
d = time.Duration(secs) * time.Second
391+
} else if t, err := http.ParseTime(v); err == nil {
392+
d = time.Until(t)
393+
if d <= 0 {
394+
return 0
395+
}
396+
} else {
397+
return 0
398+
}
399+
if d > maxRetryAfter {
400+
d = maxRetryAfter
401+
}
402+
return d
403+
}
404+
360405
// isRetryableHTTPStatus returns true for HTTP status codes that indicate
361406
// a transient error safe to retry after a backoff.
362407
func isRetryableHTTPStatus(code int) bool {

internal/llm/retry_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,59 @@ import (
99
"time"
1010
)
1111

12+
func TestParseRetryAfter(t *testing.T) {
13+
if d := parseRetryAfter("2"); d != 2*time.Second {
14+
t.Errorf("parseRetryAfter(\"2\") = %v, want 2s", d)
15+
}
16+
if d := parseRetryAfter(" 5 "); d != 5*time.Second {
17+
t.Errorf("parseRetryAfter trims and parses, got %v", d)
18+
}
19+
if d := parseRetryAfter(""); d != 0 {
20+
t.Errorf("empty header → 0, got %v", d)
21+
}
22+
if d := parseRetryAfter("garbage"); d != 0 {
23+
t.Errorf("unparseable → 0, got %v", d)
24+
}
25+
if d := parseRetryAfter("0"); d != 0 {
26+
t.Errorf("zero/negative → 0, got %v", d)
27+
}
28+
// Capped at maxRetryAfter.
29+
if d := parseRetryAfter("100000"); d != maxRetryAfter {
30+
t.Errorf("huge value should cap at %v, got %v", maxRetryAfter, d)
31+
}
32+
}
33+
34+
// TestClient_Call_HonorsRetryAfter verifies a 429 with a Retry-After header is
35+
// retried (rather than failed) and ultimately succeeds. The 1s value keeps the
36+
// test fast while exercising the header path.
37+
func TestClient_Call_HonorsRetryAfter(t *testing.T) {
38+
var callCount atomic.Int32
39+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+
if int(callCount.Add(1)) == 1 {
41+
w.Header().Set("Retry-After", "1")
42+
w.WriteHeader(http.StatusTooManyRequests)
43+
w.Write([]byte(`{"error":"slow down"}`))
44+
return
45+
}
46+
w.Header().Set("Content-Type", "application/json")
47+
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
48+
}))
49+
defer ts.Close()
50+
51+
c := New(ts.URL, "key", "model", "", 0, 10*time.Second)
52+
start := time.Now()
53+
result, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil, nil)
54+
if err != nil {
55+
t.Fatalf("unexpected error: %v", err)
56+
}
57+
if result.Content != "ok" {
58+
t.Errorf("content = %q, want ok", result.Content)
59+
}
60+
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
61+
t.Errorf("expected to wait ~1s for Retry-After, only waited %v", elapsed)
62+
}
63+
}
64+
1265
// TestClient_SimpleCall_RetryOn429 verifies the lightweight secondary calls
1366
// share the main loop's retry resilience: a transient 429 no longer aborts a
1467
// skill-match / memory / title call on the first failure.

0 commit comments

Comments
 (0)