# 重试机制技术文档 > 本文档详细描述 new-api 的请求重试机制,包括重试流程、优先级降级、权重选择、状态码判断、跨组重试、渠道亲和性、计费退款、自动禁用等完整链路。 --- ## 目录 1. [整体流程概述](#1-整体流程概述) 2. [重试次数与配置](#2-重试次数与配置) 3. [优先级降级机制](#3-优先级降级机制) 4. [权重选择机制](#4-权重选择机制) 5. [重试判断规则](#5-重试判断规则) 6. [可重试状态码范围](#6-可重试状态码范围) 7. [Auto-Group 跨组重试](#7-auto-group-跨组重试) 8. [渠道亲和性与重试](#8-渠道亲和性与重试) 9. [计费与退款](#9-计费与退款) 10. [自动禁用渠道](#10-自动禁用渠道) 11. [Task Relay 重试](#11-task-relay-重试) 12. [RetryParam 状态管理](#12-retryparam-状态管理) --- ## 1. 整体流程概述 ### 请求完整链路 ``` HTTP Request → router (SetApiRouter) → middleware/auth.go (认证) → middleware/distributor.go (初始渠道选择,retry=0) → middleware/rate_limit.go (限流) → controller/relay.go Relay() → PreConsumeBilling() (预扣费) → retry loop (retry=0 ~ RetryTimes): → getChannel() (retry=0 用中间件选好的,retry≥1 重新选择) → restore request body → relayHandler() (发送到上游) → 成功则 return → 失败则 processChannelError() + shouldRetry() 判断 → 全部失败 → BillingSession.Refund() (退款) ``` ### 关键文件与行号 | 文件 | 函数/逻辑 | 行号 | |------|----------|------| | `controller/relay.go` | `Relay()` 重试循环 | 181-234 | | `controller/relay.go` | `getChannel()` 渠道获取 | 283-313 | | `controller/relay.go` | `shouldRetry()` 重试判断 | 315-339 | | `controller/relay.go` | `processChannelError()` 错误处理 | 341-392 | | `controller/relay.go` | `shouldRetryTaskRelay()` 任务重试判断 | 604-641 | | `model/channel_cache.go` | `GetRandomSatisfiedChannel()` 优先级+权重选择 | 96-191 | | `service/channel_select.go` | `CacheGetRandomSatisfiedChannel()` 跨组重试编排 | 83-162 | | `service/channel_select.go` | `RetryParam` 结构体 | 14-46 | | `middleware/distributor.go` | 初始渠道选择 | 60-137 | | `setting/operation_setting/status_code_ranges.go` | 状态码范围配置 | 1-197 | | `service/channel_affinity.go` | 渠道亲和性重试判断 | 493-532 | | `service/billing_session.go` | 计费会话 | 1-115 | | `service/channel.go` | `ShouldDisableChannel()` 自动禁用 | 47-102 | ### 重试循环核心代码 `controller/relay.go:181-234`: ```go retryParam := &service.RetryParam{ Ctx: c, TokenGroup: relayInfo.TokenGroup, ModelName: relayInfo.OriginModelName, Retry: common.GetPointer(0), } for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { channel, channelErr := getChannel(c, relayInfo, retryParam) // ... 渠道选择、body 恢复、请求转发 ... if newAPIError == nil { return // 成功,直接返回 } processChannelError(c, ...) // 记录错误 + 可能自动禁用 if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) { break // 不可重试,退出循环 } } // 循环结束,defer 执行退款 ``` --- ## 2. 重试次数与配置 ### RetryTimes 配置 | 属性 | 值 | |------|---| | 变量 | `common.RetryTimes` | | 定义 | `common/constants.go:122` | | 默认值 | **0**(不重试,只做 1 次初始尝试) | | 存储位置 | 数据库 `options` 表,key=`RetryTimes` | | 加载方式 | `model/option.go:496-497`,`strconv.Atoi(value)` | | 配置入口 | 管理后台 → 运营设置 | ### 循环次数计算 ``` 总尝试次数 = RetryTimes + 1 ``` 循环条件 `retryParam.GetRetry() <= common.RetryTimes`: | RetryTimes 值 | 循环范围 | 总尝试次数 | |:---:|:---:|:---:| | 0 | retry=0 | 1 次(仅初始尝试) | | 1 | retry=0, 1 | 2 次 | | 3 | retry=0, 1, 2, 3 | 4 次 | | 5 | retry=0, 1, 2, 3, 4, 5 | 6 次 | ### 与优先级的关系 `shouldRetry()` 的 `retryTimes` 参数 = `common.RetryTimes - retryParam.GetRetry()`,表示剩余重试次数。当 `retryTimes <= 0` 时不会重试。 --- ## 3. 优先级降级机制 ### 核心算法 `model/channel_cache.go:125-142`: ```go // 1. 收集所有唯一优先级值 uniquePriorities := make(map[int]bool) for _, channelId := range channels { uniquePriorities[int(channel.GetPriority())] = true } // 2. 降序排序(最高优先级在前) sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities))) // 3. retry index 映射到优先级层级 if retry >= len(uniquePriorities) { retry = len(uniquePriorities) - 1 // 限制为最低优先级 } targetPriority := int64(sortedUniquePriorities[retry]) ``` ### 映射规则 ``` retry=0 → 最高优先级(sortedUniquePriorities[0]) retry=1 → 次高优先级(sortedUniquePriorities[1]) ... retry ≥ 层级数 → 降为最低优先级(不再降级) ``` ### 示例场景 假设某模型有 5 个渠道: | 渠道 | 优先级 | 权重 | |------|:---:|:---:| | A | 100 | 50 | | B | 100 | 30 | | C | 50 | 80 | | D | 10 | 20 | | E | 10 | 10 | 唯一优先级(降序):`[100, 50, 10]` | 尝试 | retry 值 | 选择优先级 | 候选渠道 | |:---:|:---:|:---:|:---:| | 第 1 次 | 0 | 100 | A, B | | 第 2 次 | 1 | 50 | C | | 第 3 次 | 2 | 10 | D, E | | 第 4 次+ | 超出范围,降为 2(最低优先级) | 10 | D, E | --- ## 4. 权重选择机制 ### 加权随机算法 `model/channel_cache.go:145-188`: 在同一优先级的候选渠道中,使用加权随机选择: ```go totalWeight := sumWeight * smoothingFactor randomWeight := rand.Intn(totalWeight) for _, channel := range targetChannels { randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment if randomWeight < 0 { return channel, nil } } ``` ### 权重特殊处理 `model/channel_cache.go:162-174`: | 场景 | 处理 | |------|------| | 所有渠道 weight=0 | 每个 channel 的 effective weight = 100(均等选择) | | 平均 weight < 10 | 乘以 smoothingFactor=100,提高区分度 | | 正常 weight | 直接使用配置的 weight 值 | ### 示例 渠道 A(weight=70)和 渠道 B(weight=30)在同一优先级: - totalWeight = 100 - randomWeight ∈ [0, 100) - A 被选中的概率 = 70% - B 被选中的概率 = 30% E2E 测试验证:30 次请求中,21 次到 A(70.0%),9 次到 B(30.0%),与配置完全一致。 --- ## 5. 重试判断规则 ### shouldRetry() 完整判断链 `controller/relay.go:315-339`: ``` shouldRetry(c, openaiErr, retryTimes) │ ├─ openaiErr == nil? → 不重试 (false) │ ├─ 渠道亲和性跳过? → 不重试 (false) │ ShouldSkipRetryAfterChannelAffinityFailure(c) │ ├─ 是 channel error? → 必须重试 (true) ← 优先级最高 │ IsChannelError(openaiErr) 错误码以 "channel:" 开头 │ ├─ 是 skip-retry error? → 不重试 (false) │ IsSkipRetryError(openaiErr) skipRetry=true 标记 │ ├─ retryTimes <= 0? → 不重试 (false) │ 剩余重试次数用完 │ ├─ 2xx 状态码? → 不重试 (false) │ code >= 200 && code < 300 │ ├─ 非 HTTP 状态码? → 重试 (true) │ code < 100 || code > 599 连接/传输层错误 │ └─ 按状态码范围判断 → ShouldRetryByStatusCode(code) ``` ### Channel Error(必重试) 以下错误码**始终触发重试**,无论其他条件: | 错误码 | 说明 | |--------|------| | `channel:no_available_key` | 渠道无可用 API Key | | `channel:param_override_invalid` | 参数覆写无效 | | `channel:header_override_invalid` | Header 覆写无效 | | `channel:model_mapped_error` | 模型映射错误 | | `channel:aws_client_error` | AWS 客户端错误 | | `channel:invalid_key` | 无效的 API Key | | `channel:response_time_exceeded` | 响应超时 | ### Skip-Retry Error(不重试) 以下场景标记为 `skipRetry=true`,不触发重试: - 请求体读取失败 - 渠道未找到 - 计费/预扣费失败 - 敏感词检测 - 各种内部错误 --- ## 6. 可重试状态码范围 ### 默认配置 `setting/operation_setting/status_code_ranges.go:19-32`: ```go AutomaticRetryStatusCodeRanges = []StatusCodeRange{ {Start: 100, End: 199}, // 1xx 信息响应 {Start: 300, End: 399}, // 3xx 重定向 {Start: 401, End: 407}, // 认证错误 {Start: 409, End: 499}, // 409-499 客户端错误 {Start: 500, End: 503}, // 500-503 服务端错误 {Start: 505, End: 523}, // 505-523 服务端错误 {Start: 525, End: 599}, // 525-599 服务端错误 } alwaysSkipRetryStatusCodes = {504, 524} // 超时不重试 ``` ### 完整状态码速查表 | 状态码范围 | 是否重试 | 说明 | |-----------|:---:|------| | 100-199 | 重试 | 信息响应 | | 200-299 | **不重试** | 成功响应 | | 300-399 | 重试 | 重定向 | | 400 | **不重试** | 不在范围内 | | 401-407 | 重试 | 认证错误 | | 408 | **不重试** | Request Timeout(不在范围内) | | 409-499 | 重试 | 各种客户端错误 | | 500-503 | 重试 | 服务端错误 | | **504** | **不重试** | Gateway Timeout(always-skip) | | 505-523 | 重试 | 服务端错误 | | **524** | **不重试** | Cloudflare Timeout(always-skip) | | 525-599 | 重试 | 服务端错误 | | <100 或 >599 | 重试 | 非 HTTP 状态(传输层错误) | ### 自定义配置 管理员可通过运营设置修改 `AutomaticRetryStatusCodeRanges`,格式为逗号分隔的范围: ``` "500-503,525-599" # 范围表示 "429" # 单个状态码 "500-503,429,525-599" # 混合使用 ``` 解析函数 `ParseHTTPStatusCodeRanges()` 会自动排序、合并重叠范围。 --- ## 7. Auto-Group 跨组重试 ### 触发条件 当 Token 的 `group` 字段为 `"auto"` 时启用。系统会在多个渠道组之间依次尝试,每个组内耗尽所有优先级后才切换到下一个组。 ### 跨组重试开关 需要在 Token 上启用 `cross_group_retry`,存储在 context key `ContextKeyTokenCrossGroupRetry` 中。**未启用时只在第一个匹配的组内重试。** ### 状态跟踪 通过 Gin Context 中的三个 key 跟踪状态: | Context Key | 说明 | |-------------|------| | `ContextKeyAutoGroupIndex` | 当前正在尝试的组索引 | | `ContextKeyAutoGroupRetryIndex` | 切换到当前组时的全局 retry 计数 | | `ContextKeyAutoGroup` | 当前选中的组名 | ### 示例流程 2 个组,每组 2 个优先级,`RetryTimes=3`: ``` Retry=0: GroupA, priority0 (startRetryIndex=0, priorityRetry=0) → GroupA 最高优先级渠道 Retry=1: GroupA, priority1 (startRetryIndex=0, priorityRetry=1) → GroupA 次高优先级渠道 Retry=2: GroupA 优先级用完 → GroupB, priority0 (startRetryIndex=2, priorityRetry=0) → GroupB 最高优先级渠道 Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1) → GroupB 次高优先级渠道 ``` ### 组切换逻辑 `service/channel_select.go:106-154`: ```go for i := startGroupIndex; i < len(autoGroups); i++ { priorityRetry := param.GetRetry() if i > startGroupIndex { priorityRetry = 0 // 新组从最高优先级开始 } channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) if channel == nil { // 当前组没有该模型/优先级的可用渠道,切换到下一组 common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1) param.SetRetry(0) continue } // 跨组重试:当前组重试次数耗尽,准备切换 if crossGroupRetry && priorityRetry >= common.RetryTimes { common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1) param.SetRetry(0) param.ResetRetryNextTry() // 防止外层循环再 +1 } break } ``` --- ## 8. 渠道亲和性与重试 ### 亲和性原理 渠道亲和性(Channel Affinity)是一种缓存机制:对于相同用户+模型的请求,优先使用上次成功的渠道,以保持会话连续性(如 Claude Code 的对话上下文)。 ### 对重试的影响 `service/channel_affinity.go:493-506`: 当亲和性选择的渠道请求失败时,可能**跳过重试**: ``` ShouldSkipRetryAfterChannelAffinityFailure(c) → 检查 ginKeyChannelAffinitySkipRetry 标记 → true = 不重试(直接返回错误) → false = 正常重试(默认行为) ``` ### 亲和性标记设置 `service/channel_affinity.go:508-532`: ```go func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) { // ... c.Set(ginKeyChannelAffinitySkipRetry, meta.SkipRetry) // ... } ``` 标记值来自亲和性规则的 `SkipRetryOnFailure` 字段。 ### 默认规则 | 规则名 | 匹配模型 | 端点 | 亲和性 Key | SkipRetryOnFailure | |--------|---------|------|-----------|:---:| | codex trace | `^gpt-.*$` | `/v1/responses` | `prompt_cache_key` | **false** | | claude code trace | `^claude-.*$` | `/v1/messages` | `metadata.user_id` | **false** | 默认两条规则都**不阻止重试**。只有管理员创建了 `SkipRetryOnFailure=true` 的规则时,亲和性失败才会跳过重试。 ### 亲和性记录时机 - **记录**:请求成功(status < 400)后,`middleware/distributor.go:135` 调用 `RecordChannelAffinity()` - **查询**:初始渠道选择时,`distributor.go:86-107` 调用 `GetPreferredChannelByAffinity()` --- ## 9. 计费与退款 ### 计费生命周期 ``` PreConsumeBilling() ← 预扣费(retry 循环之前) │ ├─ Retry 0: 尝试请求 ─── 成功 → Settle() 结算 → 结束 │ └─ 失败 → 继续重试 ├─ Retry 1: 尝试请求 ─── 成功 → Settle() 结算 → 结束 │ └─ 失败 → 继续重试 ├─ ... │ └─ 全部失败 → BillingSession.Refund() ← defer 函数,只执行一次 ``` ### 关键特性 1. **预扣费只一次**:在 retry 循环开始前执行 `PreConsumeBilling()`(`controller/relay.go:164`) 2. **多次重试复用同一 BillingSession**:不重复扣费 3. **退款只一次**:通过 `defer` 函数(`controller/relay.go:170-179`),仅在 `newAPIError != nil` 时触发 4. **退款幂等**:`BillingSession.Refund()` 检查 `settled`、`refunded`、`fundingSettled` 标记 5. **异步执行**:退款操作通过 `gopool.Go()` 异步执行 ### 退款流程代码 ```go defer func() { if newAPIError != nil { newAPIError = service.NormalizeViolationFeeError(newAPIError) if relayInfo.Billing != nil { relayInfo.Billing.Refund(c) // 退还预扣额度 } service.ChargeViolationFeeIfNeeded(c, relayInfo, newAPIError) } }() ``` ### 信任用户优化 高余额用户(Trust Quota Bypass)跳过预扣费,`preConsumedQuota` 为 0,直接在 Settle 时扣费。 --- ## 10. 自动禁用渠道 ### 触发时机 每次 relay 失败后调用 `processChannelError()`(`controller/relay.go:229`),其中包含自动禁用逻辑。 ### 判断条件 `service/channel.go:47-102` `ShouldDisableChannel()`: | 条件 | 说明 | |------|------| | `AutomaticDisableChannelEnabled = false` | 全局开关关闭则不触发 | | `IsChannelError(err)` | `channel:*` 错误码,始终触发 | | `ShouldDisableByStatusCode(code)` | 默认 401 触发 | | 403 + Gemini 渠道 | Gemini 特有 | | 错误码匹配 | `invalid_api_key`、`account_deactivated`、`billing_not_active`、`Arrearage` 等 | | 错误类型匹配 | `insufficient_quota`、`authentication_error`、`permission_error`、`forbidden` | | 关键词匹配 | `AutomaticDisableKeywords` 中的关键词 | ### 执行方式 ```go if service.ShouldDisableChannel(channelType, err) && channelError.AutoBan { gopool.Go(func() { service.DisableChannel(channelError, err.ErrorWithStatusCode()) }) } ``` - **异步执行**:通过 `gopool.Go()` 不阻塞请求处理 - **AutoBan 控制**:每个渠道可配置是否允许自动禁用(`auto_ban` 字段) - **自动恢复**:当 `AutomaticEnableChannelEnabled=true` 时,渠道后续请求成功会自动重新启用 ### 自动禁用状态码范围 ```go AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}} ``` 默认只有 401(未授权)触发自动禁用。可自定义。 --- ## 11. Task Relay 重试 ### 适用场景 异步任务提交(Midjourney、Suno 等非实时请求),使用 `RelayTask()` 函数(`controller/relay.go:476`)。 ### 独立的重试判断 `shouldRetryTaskRelay()`(`controller/relay.go:604-641`)与主 `shouldRetry()` 的区别: | 条件 | shouldRetry() | shouldRetryTaskRelay() | |------|:---:|:---:| | channel error | 必重试 | 不检查 | | skip-retry 标记 | 不重试 | 不检查 | | 429 | 按状态码范围判断 | **必重试** | | 307 | 按状态码范围判断 | **必重试** | | 5xx | 按状态码范围判断 | 重试(排除 504/524) | | 400 | 不在默认范围,不重试 | **明确不重试** | | 408 | 不在默认范围,不重试 | **明确不重试**(Azure 超时) | | LocalError | 不适用 | **不重试** | | 渠道亲和性 | 检查 | 检查 | | 2xx | 不重试 | 不重试 | | 其他 | 委托 ShouldRetryByStatusCode | **重试**(兜底) | ### Locked Channel Task Relay 支持 `LockedChannel`:如果 relayInfo 中有锁定的渠道,所有重试都使用该渠道(不切换)。 --- ## 12. RetryParam 状态管理 ### 结构体定义 `service/channel_select.go:14-20`: ```go type RetryParam struct { Ctx *gin.Context TokenGroup string ModelName string Retry *int // 当前重试计数 resetNextTry bool // 标记下一次 IncreaseRetry 是否跳过 } ``` ### 方法 | 方法 | 说明 | |------|------| | `GetRetry() int` | 返回当前 retry 值,nil 时返回 0 | | `SetRetry(retry int)` | 直接设置 retry 值 | | `IncreaseRetry()` | retry + 1;若 `resetNextTry=true` 则跳过并重置标记 | | `ResetRetryNextTry()` | 设置 `resetNextTry=true` | ### resetNextTry 机制 用于 Auto-Group 跨组重试时的状态管理,防止 retry 计数器被双重递增: ``` 场景:GroupA 重试用完,准备切换到 GroupB 1. CacheGetRandomSatisfiedChannel 检测到 priorityRetry >= RetryTimes 2. 调用 param.SetRetry(0) ← 重置计数器 3. 调用 param.ResetRetryNextTry() ← 标记下次跳过 4. 外层 for 循环调用 IncreaseRetry() 5. 检测到 resetNextTry=true → 跳过递增,重置标记 6. 结果:retry 保持为 0,GroupB 从最高优先级开始 ``` 如果不跳过,retry 会从 0 变成 1,导致 GroupB 直接从次高优先级开始,跳过了最高优先级的渠道。 ### Context Key `constant/context_key.go`: ```go ContextKeyAutoGroup // 当前 auto-group 名称 ContextKeyAutoGroupIndex // 当前组索引 ContextKeyAutoGroupRetryIndex // 当前组开始时的全局 retry 数 ContextKeyTokenCrossGroupRetry // 是否启用跨组重试 ``` --- ## 附录:重试链路追踪 ### 日志 重试过程中使用了多个渠道时,循环结束后会打印重试链路: ```go useChannel := c.GetStringSlice("use_channel") if len(useChannel) > 1 { retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(...)) logger.LogInfo(c, retryLogStr) } ``` 输出示例:`重试:5->12->8`(渠道 ID 5 失败后重试到 12,再到 8)。 ### 错误日志 每次失败的渠道信息都会记录到数据库 `logs` 表(需开启 `ErrorLogEnabled`),包含: - 渠道 ID、名称、类型 - 状态码、错误类型、错误码 - 上游请求 ID - `use_channel` 数组(重试链路) - 渠道亲和性信息