From 718274bd1511bab17b5579d8517a39607d89bbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E6=96=87=E9=94=8B0668000834?= Date: Thu, 12 Mar 2026 20:42:43 +0800 Subject: [PATCH] fix(providers): classify transport layer errors as retriable for fallback chain Add transport layer error patterns (connection reset by peer, connection refused, no route to host, unexpected EOF, broken pipe, etc.) to the error classifier. These errors are now classified as FailoverTimeout, making them retriable and allowing the fallback chain to continue to the next model instead of aborting. Fixes #1419 --- pkg/providers/error_classifier.go | 18 +++++++++++++++ pkg/providers/error_classifier_test.go | 32 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..48b11c237 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -50,6 +50,21 @@ var ( substr("context deadline exceeded"), } + // Transport layer error patterns that should trigger fallback. + transportPatterns = []errorPattern{ + substr("connection reset by peer"), + substr("connection refused"), + substr("no route to host"), + substr("unexpected eof"), + substr("broken pipe"), + substr("connection closed"), + substr("connection reset"), + substr("eof"), + substr("network is unreachable"), + substr("temporary failure in name resolution"), + substr("dial tcp"), + } + billingPatterns = []errorPattern{ rxp(`\b402\b`), substr("payment required"), @@ -195,6 +210,9 @@ func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, timeoutPatterns) { return FailoverTimeout } + if matchesAny(msg, transportPatterns) { + return FailoverTimeout // Transport errors treated as timeout (retriable) + } if matchesAny(msg, authPatterns) { return FailoverAuth } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 67d9af62b..8806572ef 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -336,3 +336,35 @@ func TestIsImageSizeError(t *testing.T) { t.Error("should not match normal error") } } + +func TestClassifyError_TransportPatterns(t *testing.T) { + patterns := []string{ + "connection reset by peer", + "connection refused", + "no route to host", + "unexpected EOF", + "broken pipe", + "connection closed", + "connection reset", + "EOF", + "network is unreachable", + "temporary failure in name resolution", + "dial tcp: lookup api.openrouter.ai: no such host", + "read tcp 10.0.0.1:12345->10.0.0.2:443: connection reset by peer", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openrouter", "claude-3-opus") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverTimeout { + t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason) + } + if !result.IsRetriable() { + t.Errorf("pattern %q: should be retriable", msg) + } + } +}