fix(core): retry bare stream EOF errors
- commit
- c728340a5fdadf1225566fb6add44a478d9cedef
- author
- brice · · 2026-08-23 09:29 UTC
- parent
- edaaa7c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +46 -8
M packages/core/agent.go +26 -8
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "io" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "time" | |
| @@ -791,13 +792,14 @@ func (a *Agent) canRetryError(err error, attempt int) bool { | |||
| 791 | 792 | if msg == "" || isNonRetryableProviderLimit(msg) { | |
| 792 | 793 | return false | |
| 793 | 794 | } | |
| 795 | + | if isStreamNetworkError(err, msg) { | |
| 796 | + | return true | |
| 797 | + | } | |
| 794 | 798 | needles := []string{ | |
| 795 | 799 | "overloaded", "provider returned error", "rate limit", "ratelimit", "too many requests", | |
| 796 | 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", | |
| 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", | |
| 801 | 803 | // OpenAI's ChatGPT/Codex backend returns this generic message (with a | |
| 802 | 804 | // request ID) for transient server failures and explicitly says | |
| 803 | 805 | // "You can retry your request". | |
| @@ -820,6 +822,24 @@ func (a *Agent) canRetryError(err error, attempt int) bool { | |||
| 820 | 822 | 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 | 843 | func isNonRetryableProviderLimit(msg string) bool { | |
| 824 | 844 | needles := []string{ | |
| 825 | 845 | "usage limit", "monthly usage limit", "freeusagelimit", "gousagelimit", | |
| @@ -850,10 +870,8 @@ func classifyRetryReason(err error) RetryReason { | |||
| 850 | 870 | if errors.Is(err, context.DeadlineExceeded) || strings.Contains(msg, "timeout") || strings.Contains(msg, "timed out") { | |
| 851 | 871 | return RetryReasonTimeout | |
| 852 | 872 | } | |
| 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 | |
| 857 | 875 | } | |
| 858 | 876 | for _, needle := range []string{"context window", "context length", "maximum context", "too many tokens"} { | |
| 859 | 877 | 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 false | 793 | 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 a | 803 | // OpenAI's ChatGPT/Codex backend returns this generic message (with a | ||
| 802 | // request ID) for transient server failures and explicitly says | 804 | // 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 false | 822 | 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 RetryReasonTimeout | 871 | 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) { | |||
| 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 | 254 | func TestAgentEmitsRetryLifecycleEvents(t *testing.T) { | |
| 235 | 255 | client := &retryFakeClient{} | |
| 236 | 256 | 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{}) | ||