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
This commit is contained in:
曾文锋0668000834 2026-03-12 20:42:43 +08:00
parent 82756fa27f
commit 718274bd15
2 changed files with 50 additions and 0 deletions

View file

@ -50,6 +50,21 @@ var (
substr("context deadline exceeded"), 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{ billingPatterns = []errorPattern{
rxp(`\b402\b`), rxp(`\b402\b`),
substr("payment required"), substr("payment required"),
@ -195,6 +210,9 @@ func classifyByMessage(msg string) FailoverReason {
if matchesAny(msg, timeoutPatterns) { if matchesAny(msg, timeoutPatterns) {
return FailoverTimeout return FailoverTimeout
} }
if matchesAny(msg, transportPatterns) {
return FailoverTimeout // Transport errors treated as timeout (retriable)
}
if matchesAny(msg, authPatterns) { if matchesAny(msg, authPatterns) {
return FailoverAuth return FailoverAuth
} }

View file

@ -336,3 +336,35 @@ func TestIsImageSizeError(t *testing.T) {
t.Error("should not match normal error") 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)
}
}
}