detached 07cbc98

Merge pull request #155 from bnema/fix/retry-bare-stream-eof

fix(core): retry bare stream EOF errors
commit
07cbc9804ac22208c26961540798438d0059ab3a
author
bnema · · 2026-08-23 09:42 UTC
committer
GitHub · · 2026-08-23 09:42 UTC
merge of
edaaa7c + c728340 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +46 -8
M packages/core/agent.go +26 -8
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "io"
89 "strings"
910 "sync"
1011 "time"
@@ -791,13 +792,14 @@ func (a *Agent) canRetryError(err error, attempt int) bool {
791792 if msg == "" || isNonRetryableProviderLimit(msg) {
792793 return false
793794 }
795+ if isStreamNetworkError(err, msg) {
796+ return true
797+ }
794798 needles := []string{
795799 "overloaded", "provider returned error", "rate limit", "ratelimit", "too many requests",
796800 "429", "http 429", "500", "http 500", "502", "http 502", "503", "http 503", "504", "http 504",
797- "service unavailable", "server error", "internal error", "network error", "connection error",
798- "connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers",
799- "socket hang up", "ended without", "stream ended before", "did not get a response", "timed out",
800- "timeout", "terminated", "unexpected eof", "transport failure",
801+ "service unavailable", "server error", "internal error", "ended without", "stream ended before",
802+ "did not get a response", "timed out", "timeout", "terminated",
801803 // OpenAI's ChatGPT/Codex backend returns this generic message (with a
802804 // request ID) for transient server failures and explicitly says
803805 // "You can retry your request".
@@ -820,6 +822,24 @@ func (a *Agent) canRetryError(err error, attempt int) bool {
820822 return false
821823 }
822824
825+// isStreamNetworkError detects transport errors from an opened provider stream.
826+// Keep typed EOF checks because WebSocket readers return io.EOF on a peer close,
827+// while provider-specific transports may only preserve a textual error.
828+func isStreamNetworkError(err error, msg string) bool {
829+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
830+ return true
831+ }
832+ for _, needle := range []string{
833+ "connection", "network", "unexpected eof", "broken pipe", "socket hang up",
834+ "transport failure", "upstream connect",
835+ } {
836+ if strings.Contains(msg, needle) {
837+ return true
838+ }
839+ }
840+ return false
841+}
842+
823843 func isNonRetryableProviderLimit(msg string) bool {
824844 needles := []string{
825845 "usage limit", "monthly usage limit", "freeusagelimit", "gousagelimit",
@@ -850,10 +870,8 @@ func classifyRetryReason(err error) RetryReason {
850870 if errors.Is(err, context.DeadlineExceeded) || strings.Contains(msg, "timeout") || strings.Contains(msg, "timed out") {
851871 return RetryReasonTimeout
852872 }
853- for _, needle := range []string{"connection", "network", "unexpected eof", "broken pipe", "socket hang up", "transport failure", "upstream connect"} {
854- if strings.Contains(msg, needle) {
855- return RetryReasonNetwork
856- }
873+ if isStreamNetworkError(err, msg) {
874+ return RetryReasonNetwork
857875 }
858876 for _, needle := range []string{"context window", "context length", "maximum context", "too many tokens"} {
859877 if strings.Contains(msg, needle) {
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "io"
8 "strings"9 "strings"
9 "sync"10 "sync"
10 "time"11 "time"
@@ -791,13 +792,14 @@ func (a *Agent) canRetryError(err error, attempt int) bool {
791 if msg == "" || isNonRetryableProviderLimit(msg) {792 if msg == "" || isNonRetryableProviderLimit(msg) {
792 return false793 return false
793 }794 }
795+ if isStreamNetworkError(err, msg) {
796+ return true
797+ }
794 needles := []string{798 needles := []string{
795 "overloaded", "provider returned error", "rate limit", "ratelimit", "too many requests",799 "overloaded", "provider returned error", "rate limit", "ratelimit", "too many requests",
796 "429", "http 429", "500", "http 500", "502", "http 502", "503", "http 503", "504", "http 504",800 "429", "http 429", "500", "http 500", "502", "http 502", "503", "http 503", "504", "http 504",
797- "service unavailable", "server error", "internal error", "network error", "connection error",801+ "service unavailable", "server error", "internal error", "ended without", "stream ended before",
798- "connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers",802+ "did not get a response", "timed out", "timeout", "terminated",
799- "socket hang up", "ended without", "stream ended before", "did not get a response", "timed out",
800- "timeout", "terminated", "unexpected eof", "transport failure",
801 // OpenAI's ChatGPT/Codex backend returns this generic message (with a803 // OpenAI's ChatGPT/Codex backend returns this generic message (with a
802 // request ID) for transient server failures and explicitly says804 // request ID) for transient server failures and explicitly says
803 // "You can retry your request".805 // "You can retry your request".
@@ -820,6 +822,24 @@ func (a *Agent) canRetryError(err error, attempt int) bool {
820 return false822 return false
821 }823 }
822 824
825+// isStreamNetworkError detects transport errors from an opened provider stream.
826+// Keep typed EOF checks because WebSocket readers return io.EOF on a peer close,
827+// while provider-specific transports may only preserve a textual error.
828+func isStreamNetworkError(err error, msg string) bool {
829+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
830+ return true
831+ }
832+ for _, needle := range []string{
833+ "connection", "network", "unexpected eof", "broken pipe", "socket hang up",
834+ "transport failure", "upstream connect",
835+ } {
836+ if strings.Contains(msg, needle) {
837+ return true
838+ }
839+ }
840+ return false
841+}
842+
823 func isNonRetryableProviderLimit(msg string) bool {843 func isNonRetryableProviderLimit(msg string) bool {
824 needles := []string{844 needles := []string{
825 "usage limit", "monthly usage limit", "freeusagelimit", "gousagelimit",845 "usage limit", "monthly usage limit", "freeusagelimit", "gousagelimit",
@@ -850,10 +870,8 @@ func classifyRetryReason(err error) RetryReason {
850 if errors.Is(err, context.DeadlineExceeded) || strings.Contains(msg, "timeout") || strings.Contains(msg, "timed out") {870 if errors.Is(err, context.DeadlineExceeded) || strings.Contains(msg, "timeout") || strings.Contains(msg, "timed out") {
851 return RetryReasonTimeout871 return RetryReasonTimeout
852 }872 }
853- for _, needle := range []string{"connection", "network", "unexpected eof", "broken pipe", "socket hang up", "transport failure", "upstream connect"} {873+ if isStreamNetworkError(err, msg) {
854- if strings.Contains(msg, needle) {874+ return RetryReasonNetwork
855- return RetryReasonNetwork
856- }
857 }875 }
858 for _, needle := range []string{"context window", "context length", "maximum context", "too many tokens"} {876 for _, needle := range []string{"context window", "context length", "maximum context", "too many tokens"} {
859 if strings.Contains(msg, needle) {877 if strings.Contains(msg, needle) {
M packages/core/agent_retry_test.go +20 -0
@@ -231,6 +231,26 @@ func TestAgentRetriesUnexpectedEOFStreamError(t *testing.T) {
231231 }
232232 }
233233
234+func TestAgentRetriesBareEOFStreamError(t *testing.T) {
235+ client := &retryFakeClient{firstErr: fmt.Errorf("read SSE: %w", io.EOF)}
236+ a := NewAgent(client, "fake-model", "system", Registry{})
237+ a.RetryBaseDelay = time.Millisecond
238+ var records []RetryLifecycleRecord
239+ a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
240+ records = append(records, record)
241+ }
242+
243+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
244+ t.Fatalf("Prompt returned %v", err)
245+ }
246+ if got := atomic.LoadInt32(&client.calls); got != 2 {
247+ t.Fatalf("Stream calls = %d; want 2", got)
248+ }
249+ if len(records) != 2 || records[0].Reason != RetryReasonNetwork || records[1].Reason != RetryReasonNetwork {
250+ t.Fatalf("retry lifecycle = %#v; want network failure and retry", records)
251+ }
252+}
253+
234254 func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
235255 client := &retryFakeClient{}
236256 a := NewAgent(client, "fake-model", "system", Registry{})
@@ -231,6 +231,26 @@ func TestAgentRetriesUnexpectedEOFStreamError(t *testing.T) {
231 }231 }
232 }232 }
233 233
234+func TestAgentRetriesBareEOFStreamError(t *testing.T) {
235+ client := &retryFakeClient{firstErr: fmt.Errorf("read SSE: %w", io.EOF)}
236+ a := NewAgent(client, "fake-model", "system", Registry{})
237+ a.RetryBaseDelay = time.Millisecond
238+ var records []RetryLifecycleRecord
239+ a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
240+ records = append(records, record)
241+ }
242+
243+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
244+ t.Fatalf("Prompt returned %v", err)
245+ }
246+ if got := atomic.LoadInt32(&client.calls); got != 2 {
247+ t.Fatalf("Stream calls = %d; want 2", got)
248+ }
249+ if len(records) != 2 || records[0].Reason != RetryReasonNetwork || records[1].Reason != RetryReasonNetwork {
250+ t.Fatalf("retry lifecycle = %#v; want network failure and retry", records)
251+ }
252+}
253+
234 func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {254 func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
235 client := &retryFakeClient{}255 client := &retryFakeClient{}
236 a := NewAgent(client, "fake-model", "system", Registry{})256 a := NewAgent(client, "fake-model", "system", Registry{})

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