failover_loop.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. package handler
  2. import (
  3. "context"
  4. "math/rand/v2"
  5. "net/http"
  6. "time"
  7. "github.com/gin-gonic/gin"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "go.uber.org/zap"
  11. )
  12. // TempUnscheduler 用于 HandleFailoverError 中同账号重试耗尽后的临时封禁。
  13. // GatewayService 隐式实现此接口。
  14. type TempUnscheduler interface {
  15. TempUnscheduleRetryableError(ctx context.Context, accountID int64, failoverErr *service.UpstreamFailoverError)
  16. }
  17. // FailoverAction 表示 failover 错误处理后的下一步动作
  18. type FailoverAction int
  19. const (
  20. // FailoverContinue 继续循环(同账号重试或切换账号,调用方统一 continue)
  21. FailoverContinue FailoverAction = iota
  22. // FailoverExhausted 切换次数耗尽(调用方应返回错误响应)
  23. FailoverExhausted
  24. // FailoverCanceled context 已取消(调用方应直接 return)
  25. FailoverCanceled
  26. )
  27. const (
  28. // maxSameAccountRetries 同账号重试次数默认上限(针对 RetryableOnSameAccount 错误)。
  29. // 生产调用方通常传入账号级配置 account.GetPoolModeRetryCount(),该常量仅作兜底/测试默认值。
  30. maxSameAccountRetries = 3
  31. // sameAccountRetryDelay 同账号重试间隔
  32. sameAccountRetryDelay = 500 * time.Millisecond
  33. // maxRequestScopedRetryDelay 限制请求级瞬时错误的指数退避上限,避免高重试配置
  34. // 将单次请求拖入分钟级等待。
  35. maxRequestScopedRetryDelay = 8 * time.Second
  36. // singleAccountBackoffDelay 单账号分组 503 退避重试固定延时。
  37. // Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s),
  38. // Handler 层只需短暂间隔后重新进入 Service 层即可。
  39. singleAccountBackoffDelay = 2 * time.Second
  40. // maxProfitVetoAttempts 单次请求内允许的分组利润门终检否决次数上限。
  41. // 利润否决不产生上游请求,因此不会推进 SwitchCount;没有独立上限的话,
  42. // 「选号 → 终检否决 → 重选」在候选池与账号快照短暂不一致时可以空转很久。
  43. // 取值与 maxAccountSwitches 默认值一致:混合定价的大分组仍有充分重选机会,
  44. // 同时把整池越线时的无谓选号开销限制在常数级。
  45. maxProfitVetoAttempts = 10
  46. )
  47. // profitVetoExhaustedMessage 是利润否决次数耗尽时返回给客户端的文案。
  48. // 语义上等同于「无可用账号」:候选账号都不满足分组的利润约束。
  49. const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control"
  50. func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration {
  51. if failoverErr == nil {
  52. return sameAccountRetryDelay
  53. }
  54. if failoverErr.StatusCode == http.StatusTooManyRequests && !failoverErr.SameAccountRetryDeadline.IsZero() {
  55. delay := oauth429RetryBaseDelay(failoverErr, retryCount)
  56. // Positive-only jitter cannot violate a server-provided Retry-After.
  57. return delay + time.Duration(rand.Int64N(int64(delay/5)+1))
  58. }
  59. if failoverErr.SameAccountRetryDelay > 0 {
  60. return failoverErr.SameAccountRetryDelay
  61. }
  62. if !failoverErr.RequestScopedTransient || retryCount <= 1 {
  63. return sameAccountRetryDelay
  64. }
  65. delay := sameAccountRetryDelay
  66. for i := 1; i < retryCount; i++ {
  67. if delay >= maxRequestScopedRetryDelay/2 {
  68. return maxRequestScopedRetryDelay
  69. }
  70. delay *= 2
  71. }
  72. return delay
  73. }
  74. func oauth429RetryBaseDelay(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration {
  75. delay := time.Second
  76. for i := 1; i < retryCount && delay < maxRequestScopedRetryDelay; i++ {
  77. delay *= 2
  78. }
  79. if delay > maxRequestScopedRetryDelay {
  80. delay = maxRequestScopedRetryDelay
  81. }
  82. if failoverErr.SameAccountRetryDelay > delay {
  83. delay = failoverErr.SameAccountRetryDelay
  84. }
  85. return delay
  86. }
  87. func sameAccountRetryAllowed(failoverErr *service.UpstreamFailoverError, retryCount, retryLimit int) bool {
  88. if failoverErr == nil || !failoverErr.RetryableOnSameAccount {
  89. return false
  90. }
  91. if !sameAccountRetryDeadlineAllows(failoverErr) {
  92. return false
  93. }
  94. if retryLimit <= 0 {
  95. return false
  96. }
  97. if !failoverErr.SameAccountRetryDeadline.IsZero() {
  98. wait := failoverErr.SameAccountRetryDelay
  99. if failoverErr.StatusCode == http.StatusTooManyRequests {
  100. wait = oauth429RetryBaseDelay(failoverErr, retryCount+1)
  101. wait += wait / 5 // allow for the maximum jitter before granting a retry
  102. }
  103. if wait > 0 && !time.Now().Add(wait).Before(failoverErr.SameAccountRetryDeadline) {
  104. return false
  105. }
  106. }
  107. // Error-specific caps (Grok capacity/stream-idle) remain hard limits even
  108. // when the error also carries a freshly reconstructed deadline.
  109. if failoverErr.SameAccountRetryMax > 0 {
  110. if retryLimit <= 0 {
  111. return false
  112. }
  113. if failoverErr.SameAccountRetryMax < retryLimit {
  114. retryLimit = failoverErr.SameAccountRetryMax
  115. }
  116. return retryCount < retryLimit
  117. }
  118. // Deadline and count are independent budgets; neither disables the other.
  119. return retryLimit > 0 && retryCount < retryLimit
  120. }
  121. // sameAccountRetryDeadlineAllows prevents a retry from starting after the
  122. // service-provided same-account retry window has elapsed.
  123. func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool {
  124. return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline)
  125. }
  126. // effectiveSameAccountRetryLimit applies an error-specific cap without
  127. // overriding an explicit account setting of zero (which disables retries).
  128. func effectiveSameAccountRetryLimit(failoverErr *service.UpstreamFailoverError, account *service.Account) int {
  129. if account == nil {
  130. return 0
  131. }
  132. limit := account.GetPoolModeRetryCount()
  133. if limit > 0 && failoverErr != nil && failoverErr.SameAccountRetryMax > 0 && failoverErr.SameAccountRetryMax < limit {
  134. return failoverErr.SameAccountRetryMax
  135. }
  136. return limit
  137. }
  138. // FailoverState 跨循环迭代共享的 failover 状态
  139. type FailoverState struct {
  140. SwitchCount int
  141. MaxSwitches int
  142. FailedAccountIDs map[int64]struct{}
  143. SameAccountRetryCount map[int64]int
  144. LastFailoverErr *service.UpstreamFailoverError
  145. ForceCacheBilling bool
  146. hasBoundSession bool
  147. // profitVetoedAccountIDs 记录被分组利润门终检否决的账号,是 FailedAccountIDs
  148. // 的子集。之所以单独维护:HandleSelectionExhausted 的 503 退避分支会清空
  149. // FailedAccountIDs,而利润否决在同一请求内的判定不会改变(下游倍率 D 已在
  150. // 请求开始冻结),被清空的账号会被立即重选并再次否决,形成没有任何上游请求、
  151. // SwitchCount 也不前进的活锁。清空后必须把它们放回排除集。
  152. profitVetoedAccountIDs map[int64]struct{}
  153. // profitVetoCount 本次请求累计的利润否决次数,用于 maxProfitVetoAttempts 上限。
  154. profitVetoCount int
  155. }
  156. // NewFailoverState 创建 failover 状态
  157. func NewFailoverState(maxSwitches int, hasBoundSession bool) *FailoverState {
  158. return &FailoverState{
  159. MaxSwitches: maxSwitches,
  160. FailedAccountIDs: make(map[int64]struct{}),
  161. SameAccountRetryCount: make(map[int64]int),
  162. hasBoundSession: hasBoundSession,
  163. profitVetoedAccountIDs: make(map[int64]struct{}),
  164. }
  165. }
  166. // RecordProfitVeto 记录一次分组利润门终检否决:把账号加入排除列表(同时登记到
  167. // 利润否决集,使其不被 503 退避分支清掉)并递增否决计数。
  168. //
  169. // 返回 FailoverContinue 表示调用方可以继续重选下一个账号;返回 FailoverExhausted
  170. // 表示本次请求的利润否决次数已达上限,调用方应按「无可用账号」终止,
  171. // 不得继续 continue。
  172. func (s *FailoverState) RecordProfitVeto(accountID int64) FailoverAction {
  173. s.FailedAccountIDs[accountID] = struct{}{}
  174. if s.profitVetoedAccountIDs == nil {
  175. s.profitVetoedAccountIDs = make(map[int64]struct{})
  176. }
  177. s.profitVetoedAccountIDs[accountID] = struct{}{}
  178. s.profitVetoCount++
  179. if s.profitVetoCount >= maxProfitVetoAttempts {
  180. return FailoverExhausted
  181. }
  182. return FailoverContinue
  183. }
  184. // ProfitVetoCount 返回本次请求累计的利润否决次数(供日志使用)。
  185. func (s *FailoverState) ProfitVetoCount() int { return s.profitVetoCount }
  186. // allExclusionsAreProfitVetoed 判断排除列表是否已全部由利润门否决贡献。
  187. // 此时清空 FailedAccountIDs 会被原样恢复,退避重试不会带来任何新候选。
  188. func (s *FailoverState) allExclusionsAreProfitVetoed() bool {
  189. if len(s.profitVetoedAccountIDs) == 0 || len(s.FailedAccountIDs) == 0 {
  190. return false
  191. }
  192. for id := range s.FailedAccountIDs {
  193. if _, ok := s.profitVetoedAccountIDs[id]; !ok {
  194. return false
  195. }
  196. }
  197. return true
  198. }
  199. // HandleFailoverError 处理 UpstreamFailoverError,返回下一步动作。
  200. // 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。
  201. func (s *FailoverState) HandleFailoverError(
  202. ctx context.Context,
  203. gatewayService TempUnscheduler,
  204. accountID int64,
  205. platform string,
  206. retryLimit int,
  207. failoverErr *service.UpstreamFailoverError,
  208. ) FailoverAction {
  209. // 客户端已断开:failover 只会用已取消的 context 重新选号并必然失败,
  210. // 不应再被当成账号耗尽处理(误报 502)。
  211. if ctx != nil && ctx.Err() != nil {
  212. return FailoverCanceled
  213. }
  214. s.LastFailoverErr = failoverErr
  215. if failoverErr == nil || !failoverErr.ShouldRetryNextAccount() {
  216. return FailoverExhausted
  217. }
  218. // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。
  219. retryCount := s.SameAccountRetryCount[accountID]
  220. sameAccountRetry := sameAccountRetryAllowed(failoverErr, retryCount, retryLimit)
  221. if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) {
  222. s.ForceCacheBilling = true
  223. }
  224. // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。
  225. // 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。
  226. if sameAccountRetry {
  227. s.SameAccountRetryCount[accountID]++
  228. retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID])
  229. logger.FromContext(ctx).Warn("gateway.failover_same_account_retry",
  230. zap.Int64("account_id", accountID),
  231. zap.Int("upstream_status", failoverErr.StatusCode),
  232. zap.Int("same_account_retry_count", s.SameAccountRetryCount[accountID]),
  233. zap.Int("same_account_retry_max", retryLimit),
  234. zap.Duration("retry_delay", retryDelay),
  235. )
  236. if !sleepWithContext(ctx, retryDelay) {
  237. return FailoverCanceled
  238. }
  239. return FailoverContinue
  240. }
  241. // 同账号重试用尽,执行临时封禁
  242. if failoverErr.RetryableOnSameAccount {
  243. gatewayService.TempUnscheduleRetryableError(ctx, accountID, failoverErr)
  244. }
  245. // 加入失败列表
  246. s.FailedAccountIDs[accountID] = struct{}{}
  247. // 检查是否耗尽
  248. if s.SwitchCount >= s.MaxSwitches {
  249. return FailoverExhausted
  250. }
  251. // 递增切换计数
  252. s.SwitchCount++
  253. logger.FromContext(ctx).Warn("gateway.failover_switch_account",
  254. zap.Int64("account_id", accountID),
  255. zap.Int("upstream_status", failoverErr.StatusCode),
  256. zap.Int("switch_count", s.SwitchCount),
  257. zap.Int("max_switches", s.MaxSwitches),
  258. )
  259. // Antigravity 平台换号线性递增延时
  260. if platform == service.PlatformAntigravity {
  261. delay := time.Duration(s.SwitchCount-1) * time.Second
  262. if !sleepWithContext(ctx, delay) {
  263. return FailoverCanceled
  264. }
  265. }
  266. return FailoverContinue
  267. }
  268. // HandleSelectionExhausted 处理选号失败(所有候选账号都在排除列表中)时的退避重试决策。
  269. // 针对 Antigravity 单账号分组的 503 (MODEL_CAPACITY_EXHAUSTED) 场景:
  270. // 清除排除列表、等待退避后重新选号。
  271. //
  272. // 返回 FailoverContinue 时,调用方应设置 SingleAccountRetry context 并 continue。
  273. // 返回 FailoverExhausted 时,调用方应返回错误响应。
  274. // 返回 FailoverCanceled 时,调用方应直接 return。
  275. func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAction {
  276. // 客户端已断开时选号失败是 context canceled 的必然结果,
  277. // 不代表账号耗尽,直接按取消终止。
  278. if ctx.Err() != nil {
  279. return FailoverCanceled
  280. }
  281. if s.LastFailoverErr != nil &&
  282. s.LastFailoverErr.StatusCode == http.StatusServiceUnavailable &&
  283. s.SwitchCount <= s.MaxSwitches {
  284. // 排除列表全由利润门否决贡献时,清空后会被原样恢复:退避重试拿不到
  285. // 任何新候选,而利润否决不推进 SwitchCount,退避条件将永远成立。
  286. // 这里直接判定耗尽,避免每 2s 空转一轮的活锁。
  287. if s.allExclusionsAreProfitVetoed() {
  288. logger.FromContext(ctx).Warn("gateway.failover_selection_exhausted_by_profit_veto",
  289. zap.Int("profit_veto_count", s.profitVetoCount),
  290. zap.Int("excluded_accounts", len(s.FailedAccountIDs)),
  291. )
  292. return FailoverExhausted
  293. }
  294. logger.FromContext(ctx).Warn("gateway.failover_single_account_backoff",
  295. zap.Duration("backoff_delay", singleAccountBackoffDelay),
  296. zap.Int("switch_count", s.SwitchCount),
  297. zap.Int("max_switches", s.MaxSwitches),
  298. )
  299. if !sleepWithContext(ctx, singleAccountBackoffDelay) {
  300. return FailoverCanceled
  301. }
  302. logger.FromContext(ctx).Warn("gateway.failover_single_account_retry",
  303. zap.Int("switch_count", s.SwitchCount),
  304. zap.Int("max_switches", s.MaxSwitches),
  305. )
  306. s.FailedAccountIDs = make(map[int64]struct{})
  307. // 利润门否决的账号不参与退避重试的解除:判定依据(冻结的下游倍率)在
  308. // 同一请求内不变,放它们回池只会被再次否决。
  309. for id := range s.profitVetoedAccountIDs {
  310. s.FailedAccountIDs[id] = struct{}{}
  311. }
  312. return FailoverContinue
  313. }
  314. return FailoverExhausted
  315. }
  316. // needForceCacheBilling 判断 failover 时是否需要强制缓存计费。
  317. // 粘性会话实际切换账号、或上游明确标记时,将 input_tokens 转为 cache_read 计费。
  318. func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFailoverError, sameAccountRetry bool) bool {
  319. return (hasBoundSession && !sameAccountRetry) || (failoverErr != nil && failoverErr.ForceCacheBilling)
  320. }
  321. // failoverClientGone 判断下游客户端是否已断开(请求 context 已取消)。
  322. // 客户端断开后 failover 必须静默终止:用已取消的 context 重新选号只会得到
  323. // context.Canceled,并被误报成账号耗尽(通用 502);上游 detach 的在途请求
  324. // 照常完成计费,但不再为无人接收的响应启动新的上游尝试。
  325. // 响应尚未提交时把状态码标记为 499(client closed request),供访问日志归类。
  326. func failoverClientGone(c *gin.Context) bool {
  327. if c == nil || c.Request == nil || c.Request.Context().Err() == nil {
  328. return false
  329. }
  330. // 先停 compact 心跳(接管 ResponseWriter,建立 happens-before),与
  331. // handleStreamingAwareError/errorResponse 等终结路径对齐,避免心跳
  332. // goroutine 与下面的状态标记并发触碰同一 writer。心跳已提交 200 时
  333. // 状态码已固化,不再标 499。
  334. if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
  335. return true
  336. }
  337. if !c.Writer.Written() {
  338. c.Status(statusClientClosedRequest)
  339. }
  340. return true
  341. }
  342. // sleepWithContext 等待指定时长,返回 false 表示 context 已取消。
  343. func sleepWithContext(ctx context.Context, d time.Duration) bool {
  344. if d <= 0 {
  345. return true
  346. }
  347. select {
  348. case <-ctx.Done():
  349. return false
  350. case <-time.After(d):
  351. return true
  352. }
  353. }