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) + } + } +}