diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index e5bc93ca..bb2fb8c0 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -538,6 +538,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusTooManyRequests { + s.reconcileOpenAI429State(ctx, account, resp.Header, body) + } // 401 Unauthorized: 标记账号为永久错误 if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body)) @@ -550,6 +553,39 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account return s.processOpenAIStream(c, resp.Body) } +func (s *AccountTestService) reconcileOpenAI429State(ctx context.Context, account *Account, headers http.Header, body []byte) { + if s == nil || s.accountRepo == nil || account == nil { + return + } + + var resetAt *time.Time + if calculated := calculateOpenAI429ResetTime(headers); calculated != nil { + resetAt = calculated + } else if unixTs := parseOpenAIRateLimitResetTime(body); unixTs != nil { + t := time.Unix(*unixTs, 0) + resetAt = &t + } + if resetAt == nil { + return + } + + if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil { + return + } + + now := time.Now() + account.RateLimitedAt = &now + account.RateLimitResetAt = resetAt + + if account.Status == StatusError { + if err := s.accountRepo.ClearError(ctx, account.ID); err != nil { + return + } + account.Status = StatusActive + account.ErrorMessage = "" + } +} + // testGeminiAccountConnection tests a Gemini account's connection func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error { ctx := c.Request.Context() diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 82ff0a8b..213ef52c 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -61,9 +61,10 @@ func newTestContext() (*gin.Context, *httptest.ResponseRecorder) { type openAIAccountTestRepo struct { mockAccountRepoForGemini - updatedExtra map[string]any + updatedExtra map[string]any rateLimitedID int64 rateLimitedAt *time.Time + clearedErrorID int64 } func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { @@ -77,6 +78,11 @@ func (r *openAIAccountTestRepo) SetRateLimited(_ context.Context, id int64, rese return nil } +func (r *openAIAccountTestRepo) ClearError(_ context.Context, id int64) error { + r.clearedErrorID = id + return nil +} + func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) { gin.SetMode(gin.TestMode) ctx, recorder := newTestContext() @@ -111,11 +117,11 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing. require.Contains(t, recorder.Body.String(), "test_complete") } -func TestAccountTestService_OpenAI429PersistsSnapshotWithoutRateLimit(t *testing.T) { +func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := newTestContext() - resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached"}}`) + resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":1777283883}}`) resp.Header.Set("x-codex-primary-used-percent", "100") resp.Header.Set("x-codex-primary-reset-after-seconds", "604800") resp.Header.Set("x-codex-primary-window-minutes", "10080") @@ -130,6 +136,7 @@ func TestAccountTestService_OpenAI429PersistsSnapshotWithoutRateLimit(t *testing ID: 88, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusError, Concurrency: 1, Credentials: map[string]any{"access_token": "test-token"}, } @@ -138,7 +145,7 @@ func TestAccountTestService_OpenAI429PersistsSnapshotWithoutRateLimit(t *testing require.Error(t, err) require.NotEmpty(t, repo.updatedExtra) require.Equal(t, 100.0, repo.updatedExtra["codex_5h_used_percent"]) - require.Zero(t, repo.rateLimitedID) - require.Nil(t, repo.rateLimitedAt) - require.Nil(t, account.RateLimitResetAt) + require.Equal(t, account.ID, repo.rateLimitedID) + require.NotNil(t, repo.rateLimitedAt) + require.Equal(t, account.ID, repo.clearedErrorID) } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 4730303f..9344de47 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -931,7 +931,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head // calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间 // 返回 nil 表示无法从响应头中确定重置时间 -func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *time.Time { +func calculateOpenAI429ResetTime(headers http.Header) *time.Time { snapshot := ParseCodexRateLimitHeaders(headers) if snapshot == nil { return nil @@ -977,6 +977,10 @@ func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *tim return nil } +func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *time.Time { + return calculateOpenAI429ResetTime(headers) +} + // anthropic429Result holds the parsed Anthropic 429 rate-limit information. type anthropic429Result struct { resetAt time.Time // The correct reset time to use for SetRateLimited