Skip to content

Commit da8907b

Browse files
committed
fix(llm): add retry with exponential backoff for transient API errors
Requests that fail with 429, 502, 503, 504, or network errors (EOF, connection refused, timeout) are now retried up to 3 times with exponential backoff (1s → 2s → 4s). Permanent errors (400, 401, etc.) still fail immediately. This prevents single transient API hiccups from killing the entire agent turn — previously, one 429 from DeepSeek would abort the run with no recovery. Now the agent loop continues transparently. 5 new tests: RetryOn429, RetryOn503, NoRetryOn400, RetryExhausted, RetryOnNetworkError.
1 parent 361dcac commit da8907b

2 files changed

Lines changed: 224 additions & 27 deletions

File tree

internal/llm/client.go

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -277,40 +277,89 @@ func (c *Client) Call(ctx context.Context, messages []Message, systemBlocks []Sy
277277
}
278278

279279
url := c.BaseURL + "/chat/completions"
280-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBytes))
281-
if err != nil {
282-
return nil, fmt.Errorf("llm: create request: %w", err)
283-
}
284-
req.Header.Set("Content-Type", "application/json")
285-
req.Header.Set("Authorization", "Bearer "+c.APIKey)
286280

287-
// Add Anthropic-specific API version header (required for prompt caching)
288-
// Safe to always send — other providers ignore unknown headers.
289-
req.Header.Set("anthropic-version", "2023-06-01")
281+
const maxRetries = 3
282+
var lastErr error
283+
284+
for attempt := 0; attempt <= maxRetries; attempt++ {
285+
if attempt > 0 {
286+
// Exponential backoff: 1s, 2s, 4s
287+
backoff := time.Duration(1<<(attempt-1)) * time.Second
288+
select {
289+
case <-ctx.Done():
290+
return nil, ctx.Err()
291+
case <-time.After(backoff):
292+
}
293+
}
290294

291-
resp, err := c.http.Do(req)
292-
if err != nil {
293-
return nil, fmt.Errorf("llm: %w", err)
294-
}
295-
defer resp.Body.Close()
295+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBytes))
296+
if err != nil {
297+
return nil, fmt.Errorf("llm: create request: %w", err)
298+
}
299+
req.Header.Set("Content-Type", "application/json")
300+
req.Header.Set("Authorization", "Bearer "+c.APIKey)
301+
req.Header.Set("anthropic-version", "2023-06-01")
302+
303+
resp, err := c.http.Do(req)
304+
if err != nil {
305+
lastErr = fmt.Errorf("llm: %w", err)
306+
if isRetryableNetworkError(err) {
307+
continue
308+
}
309+
return nil, lastErr
310+
}
296311

297-
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
298-
if err != nil {
299-
return nil, fmt.Errorf("llm: read response: %w", err)
300-
}
301-
if len(respBytes) > maxResponseSize {
302-
return nil, fmt.Errorf("llm: response exceeds maximum size (%d bytes)", maxResponseSize)
303-
}
312+
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
313+
resp.Body.Close()
314+
if err != nil {
315+
lastErr = fmt.Errorf("llm: read response: %w", err)
316+
continue
317+
}
318+
if len(respBytes) > maxResponseSize {
319+
return nil, fmt.Errorf("llm: response exceeds maximum size (%d bytes)", maxResponseSize)
320+
}
304321

305-
if resp.StatusCode != http.StatusOK {
306-
body := strings.TrimSpace(string(respBytes))
307-
if body != "" {
308-
return nil, fmt.Errorf("llm: %s (status %d): %s", resp.Status, resp.StatusCode, body)
322+
if resp.StatusCode != http.StatusOK {
323+
errBody := strings.TrimSpace(string(respBytes))
324+
if errBody != "" {
325+
lastErr = fmt.Errorf("llm: %s (status %d): %s", resp.Status, resp.StatusCode, errBody)
326+
} else {
327+
lastErr = fmt.Errorf("llm: %s (status %d)", resp.Status, resp.StatusCode)
328+
}
329+
if isRetryableHTTPStatus(resp.StatusCode) {
330+
continue
331+
}
332+
return nil, lastErr
309333
}
310-
return nil, fmt.Errorf("llm: %s (status %d)", resp.Status, resp.StatusCode)
334+
335+
return parseResponse(respBytes)
311336
}
312337

313-
return parseResponse(respBytes)
338+
return nil, fmt.Errorf("llm: retry exhausted (%d attempts): %w", maxRetries+1, lastErr)
339+
}
340+
341+
// isRetryableHTTPStatus returns true for HTTP status codes that indicate
342+
// a transient error safe to retry after a backoff.
343+
func isRetryableHTTPStatus(code int) bool {
344+
return code == http.StatusTooManyRequests ||
345+
code == http.StatusBadGateway ||
346+
code == http.StatusServiceUnavailable ||
347+
code == http.StatusGatewayTimeout
348+
}
349+
350+
// isRetryableNetworkError returns true for network errors that are likely
351+
// transient (connection refused, timeout, EOF before headers).
352+
func isRetryableNetworkError(err error) bool {
353+
if err == nil {
354+
return false
355+
}
356+
s := err.Error()
357+
// Common transient network error patterns
358+
return strings.Contains(s, "connection refused") ||
359+
strings.Contains(s, "connection reset") ||
360+
strings.Contains(s, "EOF") ||
361+
strings.Contains(s, "timeout") ||
362+
strings.Contains(s, "TLS handshake timeout")
314363
}
315364

316365
func parseResponse(data []byte) (*CallResult, error) {

internal/llm/retry_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package llm
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync/atomic"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestClient_Call_RetryOn429(t *testing.T) {
13+
var callCount atomic.Int32
14+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
count := int(callCount.Add(1))
16+
if count <= 2 {
17+
// First two calls return 429
18+
w.WriteHeader(http.StatusTooManyRequests)
19+
w.Header().Set("Content-Type", "application/json")
20+
w.Write([]byte(`{"error":{"message":"Rate limited"}}`))
21+
return
22+
}
23+
// Third call succeeds
24+
w.Header().Set("Content-Type", "application/json")
25+
w.Write([]byte(`{"choices":[{"message":{"content":"hello"}}]}`))
26+
}))
27+
defer ts.Close()
28+
29+
c := New(ts.URL, "key", "model", "", 10*time.Second)
30+
result, err := c.Call(context.Background(), []Message{
31+
{Role: "user", Content: "hi"},
32+
}, nil, nil)
33+
34+
if err != nil {
35+
t.Fatalf("unexpected error after retries: %v", err)
36+
}
37+
if result.Content != "hello" {
38+
t.Errorf("content = %q, want %q", result.Content, "hello")
39+
}
40+
if callCount.Load() != 3 {
41+
t.Errorf("call count = %d, want 3", callCount.Load())
42+
}
43+
}
44+
45+
func TestClient_Call_RetryOn503(t *testing.T) {
46+
var callCount atomic.Int32
47+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48+
count := int(callCount.Add(1))
49+
if count <= 1 {
50+
w.WriteHeader(http.StatusServiceUnavailable)
51+
return
52+
}
53+
w.Header().Set("Content-Type", "application/json")
54+
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
55+
}))
56+
defer ts.Close()
57+
58+
c := New(ts.URL, "key", "model", "", 10*time.Second)
59+
result, err := c.Call(context.Background(), []Message{
60+
{Role: "user", Content: "hi"},
61+
}, nil, nil)
62+
63+
if err != nil {
64+
t.Fatalf("unexpected error after retry: %v", err)
65+
}
66+
if result.Content != "ok" {
67+
t.Errorf("content = %q, want %q", result.Content, "ok")
68+
}
69+
}
70+
71+
func TestClient_Call_NoRetryOn400(t *testing.T) {
72+
var callCount atomic.Int32
73+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+
callCount.Add(1)
75+
w.WriteHeader(http.StatusBadRequest)
76+
w.Header().Set("Content-Type", "application/json")
77+
w.Write([]byte(`{"error":{"message":"bad request"}}`))
78+
}))
79+
defer ts.Close()
80+
81+
c := New(ts.URL, "key", "model", "", 10*time.Second)
82+
_, err := c.Call(context.Background(), []Message{
83+
{Role: "user", Content: "hi"},
84+
}, nil, nil)
85+
86+
if err == nil {
87+
t.Fatal("expected error for 400, got nil")
88+
}
89+
if callCount.Load() != 1 {
90+
t.Errorf("call count = %d, want 1 (no retry on 400)", callCount.Load())
91+
}
92+
}
93+
94+
func TestClient_Call_RetryExhausted(t *testing.T) {
95+
var callCount atomic.Int32
96+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97+
callCount.Add(1)
98+
w.WriteHeader(http.StatusTooManyRequests)
99+
w.Header().Set("Content-Type", "application/json")
100+
w.Write([]byte(`{"error":{"message":"always rate limited"}}`))
101+
}))
102+
defer ts.Close()
103+
104+
c := New(ts.URL, "key", "model", "", 10*time.Second)
105+
_, err := c.Call(context.Background(), []Message{
106+
{Role: "user", Content: "hi"},
107+
}, nil, nil)
108+
109+
if err == nil {
110+
t.Fatal("expected error after exhausting retries, got nil")
111+
}
112+
errStr := err.Error()
113+
if errStr == "" || errStr == "expected error after exhausting retries, got nil" {
114+
t.Error("expected non-empty error message")
115+
}
116+
// Should have tried: initial + 3 retries = 4 total
117+
if callCount.Load() != 4 {
118+
t.Errorf("call count = %d, want 4 (1 initial + 3 retries)", callCount.Load())
119+
}
120+
}
121+
122+
func TestClient_Call_RetryOnNetworkError(t *testing.T) {
123+
var callCount atomic.Int32
124+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125+
count := int(callCount.Add(1))
126+
if count <= 2 {
127+
// Simulate network error by closing the connection
128+
conn, _, _ := w.(http.Hijacker).Hijack()
129+
conn.Close()
130+
return
131+
}
132+
w.Header().Set("Content-Type", "application/json")
133+
w.Write([]byte(`{"choices":[{"message":{"content":"recovered"}}]}`))
134+
}))
135+
defer ts.Close()
136+
137+
c := New(ts.URL, "key", "model", "", 10*time.Second)
138+
result, err := c.Call(context.Background(), []Message{
139+
{Role: "user", Content: "hi"},
140+
}, nil, nil)
141+
142+
if err != nil {
143+
t.Fatalf("unexpected error: %v", err)
144+
}
145+
if result.Content != "recovered" {
146+
t.Errorf("content = %q, want %q", result.Content, "recovered")
147+
}
148+
}

0 commit comments

Comments
 (0)