Merge pull request #6510 from wall-wxk/fix/release-session-slot-on-forward-failure

fix(gateway): 转发失败的请求立即释放会话槽,不再占用整个空闲超时窗口
This commit is contained in:
Wesley Liddick
2026-09-05 16:05:06 +08:00
committed by GitHub
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 {
@@ -2176,6 +2210,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
}
}