Skip to content

Commit 7a863eb

Browse files
authored
Merge pull request #1 from hex-ci/devin/1780630050-retry-unexpected-eof
Retry upstream "unexpected EOF" errors instead of returning them to the client
2 parents 5753d1a + d34d7d7 commit 7a863eb

2 files changed

Lines changed: 75 additions & 0 deletions

File tree

sdk/cliproxy/auth/conductor.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2319,6 +2319,16 @@ func (m *Manager) shouldRetryAfterError(err error, attempt int, providers []stri
23192319
if isRequestInvalidError(err) {
23202320
return 0, false
23212321
}
2322+
// Treat transient upstream stream interruptions (e.g. "unexpected EOF")
2323+
// like a normal retryable API error instead of surfacing them to the
2324+
// client. These carry no HTTP status, so they would otherwise fall through
2325+
// the 429-only retry path below and abort the request.
2326+
if isUnexpectedEOFError(err) {
2327+
if !m.retryAllowed(attempt, providers) {
2328+
return 0, false
2329+
}
2330+
return 0, true
2331+
}
23222332
wait, found := m.closestCooldownWait(providers, model, attempt)
23232333
if found {
23242334
if wait > maxWait {
@@ -2705,6 +2715,20 @@ func errorString(err error) string {
27052715
return err.Error()
27062716
}
27072717

2718+
// isUnexpectedEOFError reports whether err represents an "unexpected EOF"
2719+
// surfaced when an upstream connection or stream is cut off mid-response.
2720+
// These are transient connection failures rather than genuine API errors, so
2721+
// callers should retry them instead of returning them to the client.
2722+
func isUnexpectedEOFError(err error) bool {
2723+
if err == nil {
2724+
return false
2725+
}
2726+
if errors.Is(err, io.ErrUnexpectedEOF) {
2727+
return true
2728+
}
2729+
return strings.Contains(strings.ToLower(err.Error()), "unexpected eof")
2730+
}
2731+
27082732
func statusCodeFromError(err error) int {
27092733
if err == nil {
27102734
return 0

sdk/cliproxy/auth/conductor_overrides_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package auth
22

33
import (
44
"context"
5+
"fmt"
6+
"io"
57
"net/http"
68
"sync"
79
"testing"
@@ -108,6 +110,55 @@ func TestManager_ShouldRetryAfterError_UsesOAuthModelAliasForCooldown(t *testing
108110
}
109111
}
110112

113+
func TestManager_ShouldRetryAfterError_RetriesUnexpectedEOF(t *testing.T) {
114+
m := NewManager(nil, nil, nil)
115+
m.SetRetryConfig(3, 30*time.Second, 0)
116+
117+
model := "test-model"
118+
auth := &Auth{ID: "auth-1", Provider: "claude"}
119+
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
120+
t.Fatalf("register auth: %v", errRegister)
121+
}
122+
123+
_, _, maxWait := m.retrySettings()
124+
125+
// A statusless "unexpected EOF" error (as produced by a truncated upstream
126+
// stream) must be retried immediately rather than surfaced to the client.
127+
wait, shouldRetry := m.shouldRetryAfterError(&Error{Message: "unexpected EOF"}, 0, []string{"claude"}, model, maxWait)
128+
if !shouldRetry {
129+
t.Fatalf("expected shouldRetry=true for unexpected EOF, got false")
130+
}
131+
if wait != 0 {
132+
t.Fatalf("expected immediate retry (wait=0), got %v", wait)
133+
}
134+
135+
// io.ErrUnexpectedEOF (possibly wrapped) is detected as well.
136+
wrapped := fmt.Errorf("read stream: %w", io.ErrUnexpectedEOF)
137+
if _, shouldRetry = m.shouldRetryAfterError(wrapped, 0, []string{"claude"}, model, maxWait); !shouldRetry {
138+
t.Fatalf("expected shouldRetry=true for wrapped io.ErrUnexpectedEOF, got false")
139+
}
140+
141+
// Retries stop once the configured request-retry count is exhausted.
142+
if _, shouldRetry = m.shouldRetryAfterError(&Error{Message: "unexpected EOF"}, 3, []string{"claude"}, model, maxWait); shouldRetry {
143+
t.Fatalf("expected shouldRetry=false on attempt=3 for request_retry=3, got true")
144+
}
145+
}
146+
147+
func TestManager_ShouldRetryAfterError_UnexpectedEOFRespectsRetryDisabled(t *testing.T) {
148+
m := NewManager(nil, nil, nil)
149+
m.SetRetryConfig(0, 30*time.Second, 0)
150+
151+
auth := &Auth{ID: "auth-1", Provider: "claude"}
152+
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
153+
t.Fatalf("register auth: %v", errRegister)
154+
}
155+
156+
_, _, maxWait := m.retrySettings()
157+
if _, shouldRetry := m.shouldRetryAfterError(&Error{Message: "unexpected EOF"}, 0, []string{"claude"}, "test-model", maxWait); shouldRetry {
158+
t.Fatalf("expected shouldRetry=false when request-retry=0, got true")
159+
}
160+
}
161+
111162
type credentialRetryLimitExecutor struct {
112163
id string
113164

0 commit comments

Comments
 (0)