From 3866da508ff1c28e2f4743145d8d1d7e739bc3b6 Mon Sep 17 00:00:00 2001 From: zhengshan Date: Tue, 7 Jul 2026 23:17:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(ratelimit):=20Anthropic=20=E6=97=A0=20reset?= =?UTF-8?q?=20=E5=A4=B4=E7=9A=84=20429=20=E4=B9=9F=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 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) --- .../service/rate_limit_429_cooldown_test.go | 39 +++++++++++++++++++ backend/internal/service/ratelimit_service.go | 7 +++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/rate_limit_429_cooldown_test.go b/backend/internal/service/rate_limit_429_cooldown_test.go index fb7e0dd7af..96fc199096 100644 --- a/backend/internal/service/rate_limit_429_cooldown_test.go +++ b/backend/internal/service/rate_limit_429_cooldown_test.go @@ -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{} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 50d38e7d13..100b240785 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -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 }