fix(ratelimit): Anthropic 无 reset 头的 429 也进入兜底冷却

此前 Anthropic 429 若响应头无窗口重置时间(如 Extra usage required),
被视为"非真实限流"直接跳过标记。后果:账号永不冷却,调度器让每个
请求反复撞同一批持续 429 的账号——failover 预算烧尽后客户端稳定
收到 429(生产实测:一次请求 1.3s 内连换 4 账号全 429,而 DB 中
这些账号的限流状态毫无更新)。

改为与其他平台一致走 apply429FallbackRateLimit 秒级兜底回避:
- 默认 5s,管理端 RateLimit429CooldownSettings 可调 1~7200s
- 管理端关闭该设置即恢复旧行为(不标记)
- 日志 reason 标记为 anthropic_no_reset_time 便于运营区分

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhengshan
2026-07-07 23:19:23 +08:00
co-authored by Claude Opus 4.8
parent f68f3b86c6
commit 3866da508f
2 changed files with 44 additions and 2 deletions
@@ -97,6 +97,45 @@ func TestHandle429_FallbackDisabledSkipsLocalMark(t *testing.T) {
require.Zero(t, accountRepo.rateLimitCalls)
}
// Anthropic 无 reset 头的 429(如 Extra usage required)也应走兜底冷却,
// 否则账号永不冷却,调度器会让每个请求反复撞同一批 429 账号(旋转木马)。
func TestHandle429_AnthropicNoResetTimeUsesFallbackCooldown(t *testing.T) {
accountRepo := &rateLimit429AccountRepoStub{}
settingRepo := newMockSettingRepo()
data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: true, CooldownSeconds: 12})
settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
settingSvc := NewSettingService(settingRepo, &config.Config{})
svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil)
svc.SetSettingService(settingSvc)
account := &Account{ID: 45, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
before := time.Now()
svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"Extra usage required"}}`))
after := time.Now()
require.Equal(t, 1, accountRepo.rateLimitCalls)
require.Equal(t, int64(45), accountRepo.lastRateLimitID)
require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(12*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(12*time.Second)))
}
// 管理端关闭兜底冷却时,Anthropic 无 reset 头的 429 保持旧行为:不标记账号。
func TestHandle429_AnthropicNoResetTimeFallbackDisabledSkipsMark(t *testing.T) {
accountRepo := &rateLimit429AccountRepoStub{}
settingRepo := newMockSettingRepo()
data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12})
settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
settingSvc := NewSettingService(settingRepo, &config.Config{})
svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil)
svc.SetSettingService(settingSvc)
account := &Account{ID: 46, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"Extra usage required"}}`))
require.Zero(t, accountRepo.rateLimitCalls)
}
func TestHandle429_FallbackUsesDefaultSecondsWhenSettingServiceMissing(t *testing.T) {
accountRepo := &rateLimit429AccountRepoStub{}
cfg := &config.Config{}
@@ -994,12 +994,15 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
}
// Anthropic 平台:没有限流重置时间的 429 可能是非真实限流(如 Extra usage required),
// 不标记账号限流状态,直接透传错误给客户端
// 不适合按 5h/7d 窗口长时间封禁;但完全不标记会导致账号永不冷却,
// 调度器让每个请求反复撞同一批持续 429 的账号(failover 预算被白白烧掉,
// 客户端稳定收到 429)。因此同样走可配置的秒级兜底回避,管理端可调大或关闭。
if account.Platform == PlatformAnthropic {
slog.Warn("rate_limit_429_no_reset_time_skipped",
slog.Warn("rate_limit_429_no_reset_time",
"account_id", account.ID,
"platform", account.Platform,
"reason", "no rate limit reset time in headers, likely not a real rate limit")
s.apply429FallbackRateLimit(ctx, account, "anthropic_no_reset_time")
return
}