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..c1e42b4f 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -61,9 +61,12 @@ func newTestContext() (*gin.Context, *httptest.ResponseRecorder) { type openAIAccountTestRepo struct { mockAccountRepoForGemini - updatedExtra map[string]any - rateLimitedID int64 - rateLimitedAt *time.Time + updatedExtra map[string]any + rateLimitedID int64 + rateLimitedAt *time.Time + clearedErrorID int64 + setErrorID int64 + setErrorMsg string } func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { @@ -77,6 +80,17 @@ 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 (r *openAIAccountTestRepo) SetError(_ context.Context, id int64, errorMsg string) error { + r.setErrorID = id + r.setErrorMsg = errorMsg + return nil +} + func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) { gin.SetMode(gin.TestMode) ctx, recorder := newTestContext() @@ -111,11 +125,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 +144,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 +153,123 @@ 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.Equal(t, account.ID, repo.rateLimitedID) + require.NotNil(t, repo.rateLimitedAt) + require.Equal(t, account.ID, repo.clearedErrorID) + require.Equal(t, StatusActive, account.Status) + require.Empty(t, account.ErrorMessage) + require.NotNil(t, account.RateLimitResetAt) +} + +func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleError(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":"1777283883"}}`) + + repo := &openAIAccountTestRepo{} + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} + account := &Account{ + ID: 77, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusError, + ErrorMessage: "Access forbidden (403): account may be suspended or lack permissions", + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-token"}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "") + require.Error(t, err) + require.Equal(t, account.ID, repo.rateLimitedID) + require.NotNil(t, repo.rateLimitedAt) + require.Equal(t, account.ID, repo.clearedErrorID) + require.Equal(t, StatusActive, account.Status) + require.Empty(t, account.ErrorMessage) + require.NotNil(t, account.RateLimitResetAt) + require.Empty(t, repo.updatedExtra) +} + +func TestAccountTestService_OpenAI429ActiveAccountDoesNotClearError(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_in_seconds":3600}}`) + + repo := &openAIAccountTestRepo{} + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} + account := &Account{ + ID: 78, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-token"}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "") + require.Error(t, err) + require.Equal(t, account.ID, repo.rateLimitedID) + require.NotNil(t, repo.rateLimitedAt) + require.Zero(t, repo.clearedErrorID) + require.Equal(t, StatusActive, account.Status) + require.NotNil(t, account.RateLimitResetAt) +} + +func TestAccountTestService_OpenAI429WithoutResetSignalDoesNotMutateRuntimeState(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached"}}`) + + repo := &openAIAccountTestRepo{} + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} + account := &Account{ + ID: 79, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusError, + ErrorMessage: "stale 403", + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-token"}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "") + require.Error(t, err) require.Zero(t, repo.rateLimitedID) require.Nil(t, repo.rateLimitedAt) + require.Zero(t, repo.clearedErrorID) + require.Equal(t, StatusError, account.Status) + require.Equal(t, "stale 403", account.ErrorMessage) + require.Nil(t, account.RateLimitResetAt) +} + +func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusUnauthorized, `{"error":"bad token"}`) + + repo := &openAIAccountTestRepo{} + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} + account := &Account{ + ID: 80, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-token"}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "") + require.Error(t, err) + require.Equal(t, account.ID, repo.setErrorID) + require.Contains(t, repo.setErrorMsg, "Authentication failed (401)") + require.Zero(t, repo.rateLimitedID) + require.Zero(t, repo.clearedErrorID) require.Nil(t, account.RateLimitResetAt) } 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