From acec9f20e24e5e8e56f23f29fbae33ef8e5185f5 Mon Sep 17 00:00:00 2001 From: BrianBoyCN Date: Fri, 9 Oct 2026 16:05:45 +0800 Subject: [PATCH] fix(executor): do not cool accounts down for upstream_empty A heartbeat-only upstream stream (upstream_empty, introduced as a pre-relay guard in #286) says nothing about the account's health: credential, quota, and rate-limit state are all fine, and the same account may succeed on the very next request. Classifying it as a generic unavailable failure applies the default 15s cooldown with exponential backoff on repeats, so whenever the upstream empty-stream rate spikes the pool starves: every account ends up cooled down and requests queue behind unrelated timeouts. Observed live while the upstream empty-stream rate was around 40-60%. Special-case Code=upstream_empty in ClassifyError: keep Failover=true (another account serves the turn) but set Cooldown=0 so the account stays in the rotation. Generic unavailable errors keep their cooldown. --- internal/executor/classify_test.go | 37 ++++++++++++++++++++++++++++++ internal/executor/error.go | 8 +++++++ 2 files changed, 45 insertions(+) diff --git a/internal/executor/classify_test.go b/internal/executor/classify_test.go index 443d9767..d68478df 100644 --- a/internal/executor/classify_test.go +++ b/internal/executor/classify_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/caigee-cmd/cli2api/internal/accounts" "github.com/caigee-cmd/cli2api/internal/providers" ) @@ -271,3 +272,39 @@ func TestObserveStreamFailureIgnoresCancellation(test *testing.T) { }) } } + +func TestClassifyErrorUpstreamEmptyNoCooldown(t *testing.T) { + // A heartbeat-only empty upstream stream says nothing about the account's + // health: fail over, but never cool the account down. + err := ClassifyError(&providers.Error{ + Kind: accounts.KindUnavailable, + Status: http.StatusBadGateway, + Code: "upstream_empty", + Message: "upstream returned empty content (possible context overflow / silent reject)", + }) + if err.Code != "upstream_empty" { + t.Errorf("expected Code='upstream_empty', got '%s'", err.Code) + } + if !err.Failover { + t.Error("expected Failover=true for upstream_empty") + } + if err.Cooldown != 0 { + t.Errorf("expected Cooldown=0 for upstream_empty, got %v", err.Cooldown) + } + if err.RetryAfter != 0 { + t.Errorf("expected RetryAfter=0 for upstream_empty, got %v", err.RetryAfter) + } + if err.Kind != accounts.KindUnavailable { + t.Errorf("expected Kind='unavailable', got '%s'", err.Kind) + } + // A plain unavailable error keeps its default cooldown so only the + // upstream_empty special case is exempt. + generic := ClassifyError(&providers.Error{ + Kind: accounts.KindUnavailable, + Status: http.StatusBadGateway, + Message: "connection reset by peer", + }) + if generic.Cooldown <= 0 { + t.Errorf("expected generic unavailable to keep cooldown, got %v", generic.Cooldown) + } +} diff --git a/internal/executor/error.go b/internal/executor/error.go index cf9df9d9..5e6cb2d3 100644 --- a/internal/executor/error.go +++ b/internal/executor/error.go @@ -112,6 +112,14 @@ func ClassifyError(err error) Classified { if providerErr.Message != "" { classified.Message = providerErr.Message } + if providerErr.Code == "upstream_empty" { + // A heartbeat-only upstream stream says nothing about the account's + // health: the credential, quota, and rate-limit state are all fine, and + // the same account may succeed on the very next request. Fail over so + // another account serves the turn, but never cool this one down. + classified.Failover = true + classified.Cooldown = 0 + } if classified.Kind == accounts.KindRateLimit { switch { case providerErr.Code == "4011" && providerErr.RetryAfter <= 0: