Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/transport-failure-no-cooldown.md
Original file line number Diff line number Diff line change
@@ -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 秒,单账号路由上随后每条请求都会变成「所有账号都在冷却」。
25 changes: 25 additions & 0 deletions internal/executor/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"time"

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
85 changes: 85 additions & 0 deletions internal/executor/transport_failure_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading