fix(gateway): 客户端断开后 failover 静默终止,不再误报 502 账号耗尽

上游请求经 detachUpstreamContext(WithoutCancel) 有意脱离客户端取消(保
计费),但 failover 循环仍用原始 c.Request.Context() 重新选号:客户端
断开后上游返回 520 等可 failover 错误时,重新选号必然得到 context
canceled,被误判为账号耗尽,记录并返回通用 502。

修复:客户端已断开 ⇒ failover 静默终止。

- 新增 failoverClientGone(c):请求 ctx 已取消时先停 compact 心跳
  (建立 happens-before,对齐其它终结路径),响应未提交则标 499
  (statusClientClosedRequest,与并发槽取消路径同惯例)
- 7 个 OpenAI 内联 failover 循环(Responses/Messages/chat_completions/
  embeddings/images/grok_media/alpha_search)加双 guard:换号前 +
  选号失败分支入口;guard 位于 ReportOpenAIAccountScheduleResult(false)
  之后、RecordOpenAIAccountSwitch/池模式重试之前,账号健康副作用
  (service 层 detached ctx)不受影响
- FailoverState.HandleFailoverError/HandleSelectionExhausted 入口加
  ctx.Err() 检查返回 FailoverCanceled,取消不再改动 failover 状态;
  全部 10 个 FailoverCanceled 分支统一调用 failoverClientGone 归类 499
- 上游 detach 与计费设计不变;真实上游 520 事件仍完整落 ops
  (面板显示码 COALESCE(upstream_status_code,status_code)=520,
  错误率/告警口径不变)

测试:新增 openai_responses_failover_cancel_test.go 复现 issue 场景
(520+取消 ⇒ 不切号、499、无 502 终态)+ 在线客户端对照(正常切换、
耗尽 502);failover_loop_test.go 补入口取消用例并修正取消语义断言。

Fixes #4257
This commit is contained in:
shaw
2026-07-15 09:29:05 +08:00
parent accb918eca
commit a0593b0bf8
13 changed files with 406 additions and 11 deletions
+32
View File
@@ -5,6 +5,8 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/service"
"go.uber.org/zap"
@@ -71,6 +73,8 @@ func (s *FailoverState) HandleFailoverError(
retryLimit int,
failoverErr *service.UpstreamFailoverError,
) FailoverAction {
// 客户端已断开:failover 只会用已取消的 context 重新选号并必然失败,
// 不应再被当成账号耗尽处理(误报 502)。
if ctx != nil && ctx.Err() != nil {
return FailoverCanceled
}
@@ -141,6 +145,12 @@ func (s *FailoverState) HandleFailoverError(
// 返回 FailoverExhausted 时,调用方应返回错误响应。
// 返回 FailoverCanceled 时,调用方应直接 return。
func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAction {
// 客户端已断开时选号失败是 context canceled 的必然结果,
// 不代表账号耗尽,直接按取消终止。
if ctx.Err() != nil {
return FailoverCanceled
}
if s.LastFailoverErr != nil &&
s.LastFailoverErr.StatusCode == http.StatusServiceUnavailable &&
s.SwitchCount <= s.MaxSwitches {
@@ -169,6 +179,28 @@ func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFa
return hasBoundSession || (failoverErr != nil && failoverErr.ForceCacheBilling)
}
// failoverClientGone 判断下游客户端是否已断开(请求 context 已取消)。
// 客户端断开后 failover 必须静默终止:用已取消的 context 重新选号只会得到
// context.Canceled,并被误报成账号耗尽(通用 502);上游 detach 的在途请求
// 照常完成计费,但不再为无人接收的响应启动新的上游尝试。
// 响应尚未提交时把状态码标记为 499(client closed request),供访问日志归类。
func failoverClientGone(c *gin.Context) bool {
if c == nil || c.Request == nil || c.Request.Context().Err() == nil {
return false
}
// 先停 compact 心跳(接管 ResponseWriter,建立 happens-before),与
// handleStreamingAwareError/errorResponse 等终结路径对齐,避免心跳
// goroutine 与下面的状态标记并发触碰同一 writer。心跳已提交 200 时
// 状态码已固化,不再标 499。
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
return true
}
if !c.Writer.Written() {
c.Status(statusClientClosedRequest)
}
return true
}
// sleepWithContext 等待指定时长,返回 false 表示 context 已取消。
func sleepWithContext(ctx context.Context, d time.Duration) bool {
if d <= 0 {