From ea0aaf94fc5dc44c8123f9a534ab0974208ef580 Mon Sep 17 00:00:00 2001 From: yoruuuchan <1587761204@qq.com> Date: Thu, 29 Oct 2026 18:02:35 +0900 Subject: [PATCH] fix(executor): keep accounts routable after a transport failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dial, TLS handshake, or reset failure never reaches the upstream, but it fell through to the generic unavailable branch: 15s cooldown plus failover. On a single-account route — the normal personal setup — that parks the only usable account, and every following request is answered with "all accounts are cooling down" instead of the real transport error. Classify a *url.Error as upstream_unreachable: 502, no cooldown, no failover. Detection is shape-based, so an upstream 502 body that merely quotes "TLS handshake timeout" still cools the account as before. --- .../transport-failure-no-cooldown.md | 7 ++ internal/executor/error.go | 25 ++++++ internal/executor/transport_failure_test.go | 85 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 changelog/unreleased/transport-failure-no-cooldown.md create mode 100644 internal/executor/transport_failure_test.go diff --git a/changelog/unreleased/transport-failure-no-cooldown.md b/changelog/unreleased/transport-failure-no-cooldown.md new file mode 100644 index 00000000..9a3d4255 --- /dev/null +++ b/changelog/unreleased/transport-failure-no-cooldown.md @@ -0,0 +1,7 @@ +### English + +- A connection-level failure (dial, TLS handshake, reset) no longer puts the account into a cooldown, and no longer fails over. The request never reached the upstream, so the account stays routable and the client gets the transport error back. Previously one handshake timeout parked a healthy account for 15s, which turned a single-account route into `all accounts are cooling down` for every request that followed. + +### 中文 + +- 连接层失败(拨号、TLS 握手、连接重置)不再给账号记冷却,也不再换号。请求根本没到上游,账号保持可路由,客户端直接收到链路错误。此前一次握手超时会把健康账号锁 15 秒,单账号路由上随后每条请求都会变成「所有账号都在冷却」。 \ No newline at end of file diff --git a/internal/executor/error.go b/internal/executor/error.go index 190b37db..a791af59 100644 --- a/internal/executor/error.go +++ b/internal/executor/error.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "net/url" "strings" "time" @@ -106,6 +107,20 @@ func ClassifyError(err error) Classified { Code: "request_canceled", Message: err.Error(), } } + if isTransportFailure(err) { + // The request never reached the upstream: a dial, TLS handshake, or + // reset failure says nothing about this account's credential, quota, + // or rate limit. Cooling it with the generic unavailable default (15s) + // just parks a healthy account, and in a single-account route one + // handshake timeout turns into "all accounts are cooling down" for + // every request that follows. Surface the transport failure and let + // the client retry; the account stays routable. + return Classified{ + Kind: accounts.KindUnavailable, Status: http.StatusBadGateway, + Failover: false, Code: "upstream_unreachable", Type: "api_error", + Message: err.Error(), + } + } if providers.IsTLSVerifyError(err) { // Certificate verification is deterministic and shared by every // account: no failover, no cooldown, surface the hint as-is. @@ -179,3 +194,13 @@ func ClassifyError(err error) Classified { classified.RetryAfter = classified.Cooldown return classified } + +// isTransportFailure reports whether err is a connection-level failure (dial, +// TLS handshake, reset) rather than an answer from the upstream. http.Client +// reports every such failure as *url.Error; a response that arrived comes back +// with no error at all, so an upstream 502 whose body merely quotes +// "TLS handshake timeout" is not mistaken for one. +func isTransportFailure(err error) bool { + var urlErr *url.Error + return errors.As(err, &urlErr) +} diff --git a/internal/executor/transport_failure_test.go b/internal/executor/transport_failure_test.go new file mode 100644 index 00000000..bd40f114 --- /dev/null +++ b/internal/executor/transport_failure_test.go @@ -0,0 +1,85 @@ +package executor + +import ( + "errors" + "fmt" + "net/url" + "strings" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" +) + +// qoderHandshakeTimeout is the error shape http.Client returns when the TCP +// connect succeeded but the TLS handshake never completed: *url.Error wrapping +// a plain message, exactly what api1.qoder.sh produced in the field. +func qoderHandshakeTimeout() error { + return &url.Error{ + Op: "Post", + URL: "https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1", + Err: errors.New("net/http: TLS handshake timeout"), + } +} + +func TestClassifyErrorTransportFailureKeepsAccountRoutable(t *testing.T) { + c := ClassifyError(qoderHandshakeTimeout()) + if c.Kind != accounts.KindUnavailable || c.Status != 502 || c.Code != "upstream_unreachable" { + t.Fatalf("transport classified=%+v", c) + } + if c.Cooldown != 0 || c.RetryAfter != 0 || c.Failover { + t.Fatalf("transport failure must not cool or fail over: %+v", c) + } + if !strings.Contains(c.Message, "TLS handshake timeout") { + t.Fatalf("message must keep the transport cause: %q", c.Message) + } + + pool := NewPool(nil, nil) + pool.Upsert(Item{ID: "qoder-global", Provider: "qoder", Region: "global"}) + executor := NewChatExecutor(pool, "") + executor.markClassified("qoder-global", c, "deepseek-flash") + + item, ok := pool.ByID("qoder-global") + if !ok { + t.Fatal("account missing from pool") + } + if !item.DownUntil.IsZero() { + t.Fatalf("transport failure parked the account until %v", item.DownUntil) + } + if len(item.ModelDownUntil) != 0 { + t.Fatalf("transport failure recorded a model cooldown: %v", item.ModelDownUntil) + } + if _, ok := pool.PickRoute(RouteQuery{PublicModel: "deepseek-flash"}); !ok { + t.Fatal("account must stay routable after a transport failure") + } +} + +// A 502 whose *body* quotes the same text is an answer from the upstream (or +// from something in front of it), not a transport failure on our side: the +// generic unavailable cooldown still applies. Detection is by error shape, not +// by message text. +func TestClassifyErrorUpstreamQuotedHandshakeTimeoutStillCools(t *testing.T) { + quoted := &providers.Error{ + Kind: accounts.KindUnavailable, + Status: 502, + Message: qoderHandshakeTimeout().Error(), + } + c := ClassifyError(quoted) + if !c.Failover { + t.Fatalf("upstream 502 must still fail over: %+v", c) + } + if c.Cooldown != 15*time.Second { + t.Fatalf("upstream 502 keeps the unavailable cooldown, got %v", c.Cooldown) + } +} + +// Adapters wrap transport errors (qoder.TransportError, fmt.Errorf("%w")); the +// policy must survive the wrapping. +func TestClassifyErrorWrappedTransportFailure(t *testing.T) { + wrapped := fmt.Errorf("qoder chat: %w", qoderHandshakeTimeout()) + c := ClassifyError(wrapped) + if c.Cooldown != 0 || c.Failover || c.Code != "upstream_unreachable" { + t.Fatalf("wrapped transport classified=%+v", c) + } +}