Skip to content

紧急: OpenAI 账号 429 冷却时间误判(瞬时突发限流被当成用量限流),导致长时间 503 #2258

@qinhanlei

Description

@qinhanlei
  • 问题从五一节后开始频繁出现
  • 当前 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

当前逻辑:

  1. 如果 used_percent >= 100%(窗口真正耗尽)→ 使用对应的 reset-after-seconds ✅ 正确
  2. 如果两个窗口都没到 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
}

实际场景:

  1. OpenAI 返回 429,实际原因是 RPM 突发限流(几秒就恢复)
  2. 响应头里始终带着 x-codex-secondary-reset-after-seconds(如 ~18000 秒,即 5h 窗口剩余时间)
  3. used_percent 只有比如 30%,远未耗尽
  4. 系统进入兜底分支 → 取了两个 reset-after-seconds 中较大的 → 账号被限流数小时甚至数天
  5. 实际上 RPM 限流几秒就恢复,账号立即可用

冷却时间可能远超 5h: 因为取的是两个窗口的 max(reset-after-seconds),如果 7d 窗口还剩 5 天,冷却时间会是 ~432000 秒(5 天),不只是 5 小时。用户看到 5h 可能是 7d 窗口已接近重置的情况。

对比: 如果 429 响应里没有 Codex 头,同样这个 429 会走到 apply429FallbackRateLimit默认只需等 5 秒,且硬上限为 7200 秒(2h)。但因为 Codex 头存在,系统被错误地导向了数小时甚至数天的冷却。

补充:另一个不受 clamp 保护的路径

parseOpenAIRateLimitResetTimeratelimit_service.go:1158)从响应体中解析 usage_limit_reachedrate_limit_exceeded 错误的 resets_at 字段。这个值完全由 OpenAI 控制,没有任何本地 clamp 限制,也可以导致数小时冷却。

后果链

  1. 账号被标记 rate_limit_reset_at 为远未来时间
  2. IsSchedulable()account.go:118)检查 now < RateLimitResetAt → 返回 false → 账号被排除出调度池
  3. 所有账号都限流 → 网关返回 503
  4. 冷却到期后账号自动重回调度池(RateLimitResetAt 是一个到期时间,不是永久标记),但到期时间被错误地设得太长
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions