- 问题从五一节后开始频繁出现
- 当前 sub2api 版本为 v0.1.125,问题依然存在
现象
客户端持续收到 503 错误:
503 Service Unavailable: Service temporarily unavailable
后端管理页面显示账号被限流(429),需要等待数小时才能自动恢复。但此时上游 API 实际已无错误。手动点击"恢复状态"按钮后,账号立即恢复正常,请求正常通过。
根因分析
核心问题:429 冷却时间误判(calculateOpenAI429ResetTime)
位置: ratelimit_service.go:974 calculateOpenAI429ResetTime
OpenAI Codex 响应头中有两套用量窗口:
- Primary(7d 周限):
x-codex-primary-used-percent / x-codex-primary-reset-after-seconds
- Secondary(5h 限):
x-codex-secondary-used-percent / x-codex-secondary-reset-after-seconds
当前逻辑:
- 如果
used_percent >= 100%(窗口真正耗尽)→ 使用对应的 reset-after-seconds ✅ 正确
- 如果两个窗口都没到 100% 但收到了 429 → 取两者中较长的
reset-after-seconds ❌ 错误
// L1003-1015
// 都未达到100%但收到429,使用较长的重置时间
var maxResetSecs int
if normalized.Reset7dSeconds != nil && *normalized.Reset7dSeconds > maxResetSecs {
maxResetSecs = *normalized.Reset7dSeconds
}
if normalized.Reset5hSeconds != nil && *normalized.Reset5hSeconds > maxResetSecs {
maxResetSecs = *normalized.Reset5hSeconds
}
if maxResetSecs > 0 {
resetAt := now.Add(time.Duration(maxResetSecs) * time.Second)
return &resetAt
}
实际场景:
- OpenAI 返回 429,实际原因是 RPM 突发限流(几秒就恢复)
- 响应头里始终带着
x-codex-secondary-reset-after-seconds(如 ~18000 秒,即 5h 窗口剩余时间)
- 但
used_percent 只有比如 30%,远未耗尽
- 系统进入兜底分支 → 取了两个 reset-after-seconds 中较大的 → 账号被限流数小时甚至数天
- 实际上 RPM 限流几秒就恢复,账号立即可用
冷却时间可能远超 5h: 因为取的是两个窗口的 max(reset-after-seconds),如果 7d 窗口还剩 5 天,冷却时间会是 ~432000 秒(5 天),不只是 5 小时。用户看到 5h 可能是 7d 窗口已接近重置的情况。
对比: 如果 429 响应里没有 Codex 头,同样这个 429 会走到 apply429FallbackRateLimit,默认只需等 5 秒,且硬上限为 7200 秒(2h)。但因为 Codex 头存在,系统被错误地导向了数小时甚至数天的冷却。
补充:另一个不受 clamp 保护的路径
parseOpenAIRateLimitResetTime(ratelimit_service.go:1158)从响应体中解析 usage_limit_reached 或 rate_limit_exceeded 错误的 resets_at 字段。这个值完全由 OpenAI 控制,没有任何本地 clamp 限制,也可以导致数小时冷却。
后果链
- 账号被标记
rate_limit_reset_at 为远未来时间
IsSchedulable()(account.go:118)检查 now < RateLimitResetAt → 返回 false → 账号被排除出调度池
- 所有账号都限流 → 网关返回 503
- 冷却到期后账号自动重回调度池(
RateLimitResetAt 是一个到期时间,不是永久标记),但到期时间被错误地设得太长
- OpenAI 平台缺少"成功响应自愈"机制:
UpdateSessionWindow(L1334)中的 status == "allowed" 自愈路径依赖 anthropic-ratelimit-unified-5h-status 头,仅对 Anthropic 平台生效。OpenAI 平台目前没有任何"请求成功后自动 ClearRateLimit"的等价路径,即使账号重回调度池后,残留的限流字段也需要等下一次 UpdateSessionWindow 调用才能清除
涉及代码
| 位置 |
作用 |
ratelimit_service.go:974 calculateOpenAI429ResetTime |
解析 Codex 头,设置冷却时间(核心问题所在) |
ratelimit_service.go:1003-1015 |
兜底分支:未耗尽窗口的 429 错误地使用了窗口重置时间 |
ratelimit_service.go:1158 parseOpenAIRateLimitResetTime |
响应体解析,也不受 clamp 保护 |
ratelimit_service.go:930 apply429FallbackRateLimit |
无 Codex 头时的 fallback,默认 5 秒,最大 2h(对比参照) |
ratelimit_service.go:1242 UpdateSessionWindow |
自愈路径,但仅对 Anthropic 生效 |
account.go:118 IsSchedulable() |
检查 now < RateLimitResetAt,决定是否可调度 |
openai_gateway_handler.go:289 |
返回 503 |
解决方案
修复核心问题:冷却时间误判
calculateOpenAI429ResetTime 的兜底分支(L1003-1015)应在窗口未耗尽时返回 nil,让调用方走到 apply429FallbackRateLimit(默认 5 秒,最大 2h),而不是猜测使用窗口重置时间:
// 将 L1003-1015 替换为:
// Neither window exhausted — 429 likely from transient burst (RPM/TPM),
// not usage limits. Return nil to use short fallback cooldown.
return nil
对 parseOpenAIRateLimitResetTime 加 clamp
建议区分错误类型:
rate_limit_exceeded(突发限流):加 clamp(如最大 7200 秒 / 2h)
usage_limit_reached(真正用量耗尽):保持上游返回值不变(可能合法地需要数小时),或设更宽松的上限(如 24h)
补充:限流探测自愈
在 selectAccountForModelWithExclusions 中,当 selectBestAccount 返回 nil 时,增加限流探测回退:找到仅因限流被阻塞的账号,清除其限流状态,返回使用。安全性:仅在即将返回 503 时触发。如果账号确实仍在限流,上游会再次返回 429 并重新标记。由于 OpenAI 平台本身缺少自愈路径,这个补充方案尤其必要。
临时替代方案
配置 Scheduled Test + auto_recover(定时测试并自动恢复)。该功能直接按 account ID 发请求,不需要账号在调度池中可被选中。如果测试成功,会自动调用 ClearRateLimit 清除限流状态,绕过阻塞。相关代码:scheduled_test_runner_service.go:133。
现象
客户端持续收到 503 错误:
后端管理页面显示账号被限流(429),需要等待数小时才能自动恢复。但此时上游 API 实际已无错误。手动点击"恢复状态"按钮后,账号立即恢复正常,请求正常通过。
根因分析
核心问题:429 冷却时间误判(
calculateOpenAI429ResetTime)位置:
ratelimit_service.go:974calculateOpenAI429ResetTimeOpenAI Codex 响应头中有两套用量窗口:
x-codex-primary-used-percent/x-codex-primary-reset-after-secondsx-codex-secondary-used-percent/x-codex-secondary-reset-after-seconds当前逻辑:
used_percent >= 100%(窗口真正耗尽)→ 使用对应的reset-after-seconds✅ 正确reset-after-seconds❌ 错误实际场景:
x-codex-secondary-reset-after-seconds(如 ~18000 秒,即 5h 窗口剩余时间)used_percent只有比如 30%,远未耗尽冷却时间可能远超 5h: 因为取的是两个窗口的
max(reset-after-seconds),如果 7d 窗口还剩 5 天,冷却时间会是 ~432000 秒(5 天),不只是 5 小时。用户看到 5h 可能是 7d 窗口已接近重置的情况。对比: 如果 429 响应里没有 Codex 头,同样这个 429 会走到
apply429FallbackRateLimit,默认只需等 5 秒,且硬上限为 7200 秒(2h)。但因为 Codex 头存在,系统被错误地导向了数小时甚至数天的冷却。补充:另一个不受 clamp 保护的路径
parseOpenAIRateLimitResetTime(ratelimit_service.go:1158)从响应体中解析usage_limit_reached或rate_limit_exceeded错误的resets_at字段。这个值完全由 OpenAI 控制,没有任何本地 clamp 限制,也可以导致数小时冷却。后果链
rate_limit_reset_at为远未来时间IsSchedulable()(account.go:118)检查now < RateLimitResetAt→ 返回 false → 账号被排除出调度池RateLimitResetAt是一个到期时间,不是永久标记),但到期时间被错误地设得太长UpdateSessionWindow(L1334)中的status == "allowed"自愈路径依赖anthropic-ratelimit-unified-5h-status头,仅对 Anthropic 平台生效。OpenAI 平台目前没有任何"请求成功后自动 ClearRateLimit"的等价路径,即使账号重回调度池后,残留的限流字段也需要等下一次UpdateSessionWindow调用才能清除涉及代码
ratelimit_service.go:974calculateOpenAI429ResetTimeratelimit_service.go:1003-1015ratelimit_service.go:1158parseOpenAIRateLimitResetTimeratelimit_service.go:930apply429FallbackRateLimitratelimit_service.go:1242UpdateSessionWindowaccount.go:118IsSchedulable()now < RateLimitResetAt,决定是否可调度openai_gateway_handler.go:289解决方案
修复核心问题:冷却时间误判
calculateOpenAI429ResetTime的兜底分支(L1003-1015)应在窗口未耗尽时返回nil,让调用方走到apply429FallbackRateLimit(默认 5 秒,最大 2h),而不是猜测使用窗口重置时间:对
parseOpenAIRateLimitResetTime加 clamp建议区分错误类型:
rate_limit_exceeded(突发限流):加 clamp(如最大 7200 秒 / 2h)usage_limit_reached(真正用量耗尽):保持上游返回值不变(可能合法地需要数小时),或设更宽松的上限(如 24h)补充:限流探测自愈
在
selectAccountForModelWithExclusions中,当selectBestAccount返回 nil 时,增加限流探测回退:找到仅因限流被阻塞的账号,清除其限流状态,返回使用。安全性:仅在即将返回 503 时触发。如果账号确实仍在限流,上游会再次返回 429 并重新标记。由于 OpenAI 平台本身缺少自愈路径,这个补充方案尤其必要。临时替代方案
配置 Scheduled Test + auto_recover(定时测试并自动恢复)。该功能直接按 account ID 发请求,不需要账号在调度池中可被选中。如果测试成功,会自动调用
ClearRateLimit清除限流状态,绕过阻塞。相关代码:scheduled_test_runner_service.go:133。