fix(gateway): 转发失败的请求立即释放会话槽,不再占用整个空闲超时窗口

会话数量限制(max_sessions)此前只有空闲超时自动过期,没有任何主动释放
机制:请求在选号阶段注册会话后,即使转发失败(上游错误/客户端断开/换号
耗尽),该 session hash 也会占住槽位直到空闲超时(默认 5 分钟),导致
max_sessions 受限的账号在窗口内拒绝所有新会话。

修复:
- SessionLimitCache 新增 UnregisterSession(Redis ZREM),立即移除会话注册
- GatewayService 新增 ReleaseAccountSession,适用条件与 checkAndRegisterSession 对齐
- Messages 主路径:记录 failover 链上注册过会话槽的账号,请求最终失败时
  统一释放;failover continue / profit veto / 兜底分组切换时按次即时释放;
  成功及"上游已计量部分结果"(#5148)保持既有空闲超时语义
- CountTokens:转发失败时同样释放选号时注册的会话槽

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wall-wxk
2026-09-02 18:45:20 +08:00
co-authored by Claude Opus 4.6
parent 5097b31457
commit 9be9c0b68c
7 changed files with 289 additions and 0 deletions
@@ -610,6 +610,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
c.Request = c.Request.WithContext(ctx)
}
// 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话
// (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽),
// 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions
// 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。
// 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。
sessionSlotAccounts := make(map[int64]*service.Account)
upstreamServedSession := false
defer func() {
if upstreamServedSession {
return
}
// 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放
for _, acc := range sessionSlotAccounts {
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
}
}()
for {
fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession)
retryWithFallback := false
@@ -763,10 +780,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
return
}
// 尝试被否决(从未转发),立即释放该账号的会话注册
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
delete(sessionSlotAccounts, account.ID)
continue
}
account = latest
selection.Account = latest
// 记录本请求注册过会话槽的账号(profit 准入后账号已定)
sessionSlotAccounts[account.ID] = account
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
if selection.ProfitGateActive() || !selection.Acquired {
@@ -983,6 +1005,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
currentSubscription = nil
fallbackUsed = true
retryWithFallback = true
// 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组
for _, acc := range sessionSlotAccounts {
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
}
sessionSlotAccounts = make(map[int64]*service.Account)
break
}
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
@@ -998,6 +1025,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
switch action {
case FailoverContinue:
// 本次尝试已确定性失败,立即释放该账号的会话注册
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
delete(sessionSlotAccounts, account.ID)
continue
case FailoverExhausted:
h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted)
@@ -1036,6 +1066,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 不会走到这里重复计费。
if result != nil {
submitForwardUsage(result)
// 上游已接受并计量本次会话(流中断),会话槽保持既有语义
upstreamServedSession = true
}
return
}
@@ -1061,6 +1093,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
submitForwardUsage(result)
// 转发成功,会话槽保持既有空闲超时语义
upstreamServedSession = true
return
}
if !retryWithFallback {
@@ -2163,6 +2197,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil {
reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
// 错误响应已在 ForwardCountTokens 中处理
// 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx)
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash)
return
}
}