diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 228f0153e..e76fbcee1 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -215,7 +215,9 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( if s.rateLimitService != nil { policy = s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel) } - if policy != ErrorPolicyTempUnscheduled { + // 与 messages 兼容层一致:只有 None / Matched 才走账号状态处理。 + // Skipped(池模式、或自定义错误码未命中)与 TempUnscheduled 已由策略层裁决完毕。 + if policy == ErrorPolicyNone || policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) } evBody := unwrapIfNeeded(account.Type == AccountTypeOAuth, respBody) diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index e71bd141b..46024077a 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -489,6 +489,105 @@ func TestHandleGeminiUpstreamError_GoogleOneCapacityExhaustedUsesTierCooldown(t require.True(t, repo.lastRateLimitReset.Before(after.Add(5*time.Minute).Add(2*time.Second))) } +// --------------------------------------------------------------------------- +// TestHandleGeminiUpstreamError_PoolMode429 — 池模式账号的 429 不写账号级限流。 +// +// 429 的标记点在重试循环内(handleClaudeCompat / forwardNativeGemini / +// chat completions 三条路径),先于 CheckErrorPolicy 执行,池模式豁免只能落在 +// handleGeminiUpstreamError 自身;否则一次上游 429 会把账号锁到 PST 午夜, +// 即便重试已经成功返回客户端。 +// --------------------------------------------------------------------------- + +func TestHandleGeminiUpstreamError_PoolMode429(t *testing.T) { + // 中转上游的真实 429 文案:不含 "per day",也没有 quotaResetDelay, + // 解析失败后 apikey 账号会落到 PST 午夜兜底。 + body := []byte(`{"error":{"code":429,"message":"You have exhausted your capacity on this model. Your quota will reset after 6h53m10s."}}`) + + tests := []struct { + name string + account *Account + expectRateLimited bool + }{ + { + name: "pool_mode_apikey_stays_in_pool", + account: &Account{ + ID: 600, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"pool_mode": true}, + }, + expectRateLimited: false, + }, + { + name: "custom_error_codes_hit_overrides_pool_mode", + account: &Account{ + ID: 601, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + expectRateLimited: true, + }, + { + name: "custom_error_codes_miss_skips", + account: &Account{ + ID: 602, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(500)}, + }, + }, + expectRateLimited: false, + }, + { + name: "non_pool_apikey_still_rate_limited", + account: &Account{ + ID: 603, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + }, + expectRateLimited: true, + }, + { + name: "oauth_account_ignores_pool_mode_flag", + account: &Account{ + ID: 604, + Platform: PlatformGemini, + Type: AccountTypeOAuth, + Credentials: map[string]any{"pool_mode": true}, + }, + expectRateLimited: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + svc := &GeminiMessagesCompatService{ + accountRepo: repo, + rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil), + } + + svc.handleGeminiUpstreamError(context.Background(), tt.account, http.StatusTooManyRequests, http.Header{}, body) + + if !tt.expectRateLimited { + require.Zero(t, repo.rateLimitCalls, "池模式账号不应被标记账号级限流") + return + } + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, tt.account.ID, repo.lastRateLimitID) + require.True(t, repo.lastRateLimitReset.After(time.Now())) + }) + } +} + type geminiErrorPolicyRepo struct { mockAccountRepoForGemini setErrorCalls int diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index fcedf9a21..e62a24223 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -2959,6 +2959,11 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont if statusCode != 429 { return } + // 池模式账号不写账号级限流:账号留在池内,由 failover / 同号重试消化 429。 + // 自定义错误码优先级高于池模式,开启后仍按其命中结果标记。 + if account.IsPoolMode() && !account.IsCustomErrorCodesEnabled() { + return + } oauthType := account.GeminiOAuthType() tierID := account.GeminiTierID()