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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user