diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 3551d8ecb2..f80382c41a 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -37,9 +37,8 @@ const ( openAIOAuth429QuotaReset ) -// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。明确窗口达到 -// 100% 时以该窗口为准;没有 100% 标记但包含重置头时,沿用 v179 的兼容语义, -// 仍视为配额限流信号。 +// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。只有窗口达到 +// 100% 或响应体明确给出 reset 时间时,才视为配额限流信号。 func classifyOpenAIOAuth429(headers http.Header, responseBody []byte) (openAIOAuth429Disposition, *time.Time) { if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil { if normalized := snapshot.Normalize(); normalized != nil { @@ -228,13 +227,17 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context return } - cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown) - if resetAt != nil && resetAt.After(time.Now()) { + now := time.Now() + cooldownUntil := now.Add(openAIOAuth429FallbackCooldown) + if resetAt != nil && resetAt.After(now) { cooldownUntil = *resetAt } else if s.rateLimitService != nil { - if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 { - cooldownUntil = time.Now().Add(cooldown) + cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account) + if !ok || cooldown <= 0 { + s.openaiOAuth429RetryStartedAt.Delete(account.ID) + return } + cooldownUntil = now.Add(cooldown) } s.BlockAccountScheduling(account, cooldownUntil, "429") s.openaiOAuth429RetryStartedAt.Delete(account.ID) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 543c999359..08dd04d08c 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -72,6 +72,47 @@ func TestOpenAI429FastPath_BlocksOAuthOnlyAfterRetryWindow(t *testing.T) { require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false)) } +func TestOpenAI429FastPath_DoesNotBlockOAuthWhenFallbackDisabled(t *testing.T) { + repo := &oauth429RateLimitRepo{} + settingRepo := newMockSettingRepo() + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = `{"enabled":false,"cooldown_seconds":12}` + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingRepo, &config.Config{})) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 425, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + + svc.markOpenAIOAuth429RateLimited(context.Background(), account, http.Header{}, []byte(`{"detail":"Rate limit exceeded"}`)) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "disabled 429 fallback must not create an OAuth runtime cooldown") + require.Zero(t, repo.setRateLimitedCalls, "disabled 429 fallback must not persist a scheduler cooldown") +} + +func TestOpenAI429FastPath_DoesNotBlockOAuthWhenQuotaWindowIsNotExhausted(t *testing.T) { + repo := &oauth429RateLimitRepo{} + settingRepo := newMockSettingRepo() + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = `{"enabled":false,"cooldown_seconds":12}` + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingRepo, &config.Config{})) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 426, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-reset-after-seconds", "604800") + headers.Set("x-codex-primary-window-minutes", "10080") + headers.Set("x-codex-secondary-used-percent", "20") + headers.Set("x-codex-secondary-reset-after-seconds", "3600") + headers.Set("x-codex-secondary-window-minutes", "300") + + svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, []byte(`{"detail":"Rate limit exceeded"}`)) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "non-exhausted quota headers must use the configurable fallback") + require.Zero(t, repo.setRateLimitedCalls, "disabled 429 fallback must not persist a scheduler cooldown") +} + func TestOpenAI429FastPath_BlocksOAuthImmediatelyWhenSevenDayQuotaIsExhausted(t *testing.T) { repo := &oauth429RateLimitRepo{} rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) @@ -307,7 +348,7 @@ func TestOpenAIHTTP429StillUsesQuotaResetHeaders(t *testing.T) { account := &Account{ID: 422, Platform: PlatformOpenAI, Type: AccountTypeOAuth} svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) headers := http.Header{} - headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-used-percent", "100") headers.Set("x-codex-primary-reset-after-seconds", "604800") headers.Set("x-codex-primary-window-minutes", "10080") diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 47f234f85d..5b42f95951 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1348,20 +1348,8 @@ func calculateOpenAI429ResetTime(headers http.Header) *time.Time { return &resetAt } - // 都未达到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) - slog.Info("openai_429_using_max_reset", "max_reset_seconds", maxResetSecs, "reset_at", resetAt) - return &resetAt - } - + // 未达到100%时,reset-after 只代表窗口信息,不能证明账号配额耗尽。 + // 这类瞬时429必须回到可配置的兜底路径,避免未耗尽账号被长时间排除。 return nil } diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index 8969f7e622..ba4324d7f9 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -73,10 +73,9 @@ func TestCalculateOpenAI429ResetTime_5hExhausted(t *testing.T) { } } -func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) { +func TestCalculateOpenAI429ResetTime_NeitherExhausted_ReturnsNil(t *testing.T) { svc := &RateLimitService{} - // Neither limit at 100%, should use the longer reset time headers := http.Header{} headers.Set("x-codex-primary-used-percent", "80") headers.Set("x-codex-primary-reset-after-seconds", "100000") @@ -85,22 +84,7 @@ func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) { headers.Set("x-codex-secondary-reset-after-seconds", "5000") headers.Set("x-codex-secondary-window-minutes", "300") - before := time.Now() - resetAt := svc.calculateOpenAI429ResetTime(headers) - after := time.Now() - - if resetAt == nil { - t.Fatal("expected non-nil resetAt") - } - - // Should use the max (100000 seconds from 7d window) - expectedDuration := 100000 * time.Second - minExpected := before.Add(expectedDuration) - maxExpected := after.Add(expectedDuration) - - if resetAt.Before(minExpected) || resetAt.After(maxExpected) { - t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) - } + require.Nil(t, svc.calculateOpenAI429ResetTime(headers)) } func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) {