diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index d6f4286be..ee5b09bbb 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -914,6 +914,28 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst } func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account, upstreamMsg string, responseBody []byte) (shouldDisable bool) { + // 上游代理 / CDN 在请求到达 OpenAI API 之前就拦下时,回的是 HTML 403 页面而不是 + // {"error":{...}} 结构化错误。这类响应描述的是「这条链路 / 这个端点被挡了」, + // 不构成账号凭据或权限失效的证据——例如无效的 /v1/responses 子路径(#5334)。 + // + // 据此写账号状态会把请求级错误放大成账号级处罚:首次即 temp-unschedulable, + // 连续 openAI403DisableThreshold 次直接永久禁用;而 403 又在 failover 状态集里, + // 同一个坏请求会被逐个账号重放,足以把整组账号打下线。 + // + // 与既有口径一致:count_tokens 路径的 isOpenAIOAuthInputTokensUnsupported 已把 + // 「HTML 403 page without a structured error」按端点级响应处理; + // shouldApplyOpenAIAlphaSearchAccountErrorSideEffects 的不变式也是端点级错误 + // 只换号、不写账号错误状态。这里只跳过账号处罚,不改变 failover 行为—— + // 换个走不同代理的账号仍有可能成功。 + if isHTMLResponse(responseBody) { + slog.Warn( + "openai_403_html_body_skips_account_penalty", + "account_id", account.ID, + "upstream_message", upstreamMsg, + ) + return false + } + msg := buildForbiddenErrorMessage( "Access forbidden (403):", upstreamMsg, diff --git a/backend/internal/service/ratelimit_service_403_html_test.go b/backend/internal/service/ratelimit_service_403_html_test.go new file mode 100644 index 000000000..5c8f78259 --- /dev/null +++ b/backend/internal/service/ratelimit_service_403_html_test.go @@ -0,0 +1,176 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// countingOpenAI403CounterCache 在既有桩的基础上记录递增次数。 +// HTML 403 不仅不能处罚账号,连计数都不能加——否则后续真实的账号级 403 +// 会踩着这些"白涨"的计数提前触发永久禁用。 +type countingOpenAI403CounterCache struct { + openAI403CounterCacheStub + increments int +} + +func (s *countingOpenAI403CounterCache) IncrementOpenAI403Count(ctx context.Context, accountID int64, window int) (int64, error) { + s.increments++ + return s.openAI403CounterCacheStub.IncrementOpenAI403Count(ctx, accountID, window) +} + +type openAI403TestHarness struct { + svc *RateLimitService + repo *rateLimitAccountRepoStub + counter *countingOpenAI403CounterCache + blocker *runtimeBlockRecorder + account *Account +} + +func newOpenAI403TestHarness(t *testing.T, accountID int64, counts ...int64) *openAI403TestHarness { + t.Helper() + repo := &rateLimitAccountRepoStub{} + counter := &countingOpenAI403CounterCache{openAI403CounterCacheStub: openAI403CounterCacheStub{counts: counts}} + blocker := &runtimeBlockRecorder{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc.SetOpenAI403CounterCache(counter) + svc.SetAccountRuntimeBlocker(blocker) + return &openAI403TestHarness{ + svc: svc, + repo: repo, + counter: counter, + blocker: blocker, + account: &Account{ID: accountID, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + } +} + +func (h *openAI403TestHarness) handle(body string) bool { + return h.svc.HandleUpstreamError( + context.Background(), h.account, http.StatusForbidden, http.Header{}, []byte(body), + ) +} + +func (h *openAI403TestHarness) requireNoAccountPenalty(t *testing.T) { + t.Helper() + require.Equal(t, 0, h.repo.setErrorCalls, "端点级 403 不得永久禁用账号") + require.Equal(t, 0, h.repo.tempCalls, "端点级 403 不得把账号设为临时不可调度") + require.Empty(t, h.blocker.accounts, "端点级 403 不得触发调度阻断通知") + require.Equal(t, 0, h.counter.increments, "端点级 403 不得递增连续 403 计数") +} + +// issue #5334:无效的 /v1/responses 子路径被转发后,上游代理在到达 OpenAI API +// 之前回 HTML 403 页面。这是链路/端点级响应,不是账号凭据失效的证据。 +const openAI403HTMLBody = "\n