mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix: 修复429未耗尽额度仍触发账号退避
- 关闭429默认回避时不再创建OAuth运行时冷却 - 仅在x-codex窗口达到100%时使用上游重置时间 - 补充未耗尽额度和关闭开关的回归测试
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1344,20 +1344,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user