From 12abb547008b1882cd101c8c3c996f1cce9e521e Mon Sep 17 00:00:00 2001 From: li Date: Tue, 11 Aug 2026 10:18:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(openai):=20=E4=B8=8A=E6=B8=B8=20HTML=20403?= =?UTF-8?q?=20=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BD=93=E6=88=90=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BA=A7=E9=94=99=E8=AF=AF=E5=A4=84=E7=BD=9A=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游代理 / CDN 在请求到达 OpenAI API 之前拦下时,回的是 HTML 403 页面而不是 {"error":{...}} 结构化错误。这类响应描述的是「这条链路 / 这个端点被挡了」, 不构成账号凭据或权限失效的证据。 但 handleOpenAI403 不区分响应形态,一律按账号级 403 处理: - 首次即 SetTempUnschedulable,账号 10 分钟不可调度; - 连续 openAI403DisableThreshold(3) 次直接 SetError 永久禁用账号; - 403 又在 failover 状态集里,同一个坏请求会被逐个账号重放。 结果是一个请求级错误被放大成整组账号下线。issue #5334 给出的触发方式是 POST /v1/responses/not-exist —— 该路径能通过只做结构校验的 guardResponsesSubpath, 转发后拿回 HTML 403,任何持有 API Key 的调用方重复几次即可打穿一个分组。 附带问题:整张 HTML 页面会被拼进账号错误信息写库并显示在管理端。 仓库对这类响应早有明确口径,只是没有应用到这条路径: - openai_gateway_count_tokens.go 的 isOpenAIOAuthInputTokensUnsupported 已把 「HTML 403 page without a structured error」按端点级响应处理; - shouldApplyOpenAIAlphaSearchAccountErrorSideEffects 的既定不变式是 端点级错误只换号、不写账号错误状态。 本次复用现成的 isHTMLResponse,在 handleOpenAI403 入口跳过账号处罚:不递增 连续 403 计数、不设临时不可调度、不永久禁用。failover 行为不变 —— 换一个走 不同代理的账号仍有可能成功。 Fixes #5334 --- backend/internal/service/ratelimit_service.go | 22 +++ .../ratelimit_service_403_html_test.go | 176 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 backend/internal/service/ratelimit_service_403_html_test.go diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index d6f4286be1..ee5b09bbb4 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 0000000000..5c8f782598 --- /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 = "\n403 Forbidden" + + "

403 Forbidden

" + +func TestHandleUpstreamError_OpenAIHTML403DoesNotPenalizeAccount(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"doctype_prefixed", openAI403HTMLBody}, + {"bare_html_tag", "403 Forbidden"}, + {"leading_whitespace_and_uppercase", "\n\t Forbidden"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newOpenAI403TestHarness(t, 501, 1) + + shouldDisable := h.handle(tc.body) + + require.False(t, shouldDisable, "HTML 403 不得判定账号应下线") + h.requireNoAccountPenalty(t) + }) + } +} + +// 一个持有 API Key 的调用方反复打无效子路径时,既有实现会在第 +// openAI403DisableThreshold 次把账号永久禁用。修复后连续多少次都不该升级。 +func TestHandleUpstreamError_OpenAIHTML403RepeatedNeverEscalates(t *testing.T) { + h := newOpenAI403TestHarness(t, 502, 1, 2, 3, 4, 5) + + for i := 0; i < openAI403DisableThreshold+2; i++ { + require.False(t, h.handle(openAI403HTMLBody), "第 %d 次 HTML 403 仍不得判定账号应下线", i+1) + } + + h.requireNoAccountPenalty(t) +} + +// 对照不变式:真正的结构化 JSON 403 是账号级证据,处罚链路必须原样保留。 +// 缺了这组断言,上面的跳过逻辑一旦写宽就会把真实的封号 403 也放过去。 +func TestHandleUpstreamError_OpenAIStructured403StillPenalizes(t *testing.T) { + t.Run("first_hit_temp_unschedulable", func(t *testing.T) { + h := newOpenAI403TestHarness(t, 503, 1) + + require.True(t, h.handle(`{"error":{"message":"Your account is not authorized"}}`)) + require.Equal(t, 1, h.counter.increments) + require.Equal(t, 1, h.repo.tempCalls) + require.Equal(t, 0, h.repo.setErrorCalls) + require.Contains(t, h.repo.lastTempReason, "Your account is not authorized") + require.Len(t, h.blocker.accounts, 1) + }) + + t.Run("threshold_disables", func(t *testing.T) { + h := newOpenAI403TestHarness(t, 504, int64(openAI403DisableThreshold)) + + require.True(t, h.handle(`{"error":{"message":"workspace forbidden by policy"}}`)) + require.Equal(t, 1, h.repo.setErrorCalls) + require.Contains(t, h.repo.lastErrorMsg, "workspace forbidden by policy") + }) + + // 非 HTML 的非结构化响应(纯文本网关错误)不在本次放行范围内,维持原有处罚。 + t.Run("plain_text_body_unchanged", func(t *testing.T) { + h := newOpenAI403TestHarness(t, 505, 1) + + require.True(t, h.handle("Forbidden")) + require.Equal(t, 1, h.repo.tempCalls) + }) +} + +// 作用域守卫:放行只针对 OpenAI 平台。其他平台的 403 处理不受影响。 +func TestHandleUpstreamError_HTML403OnOtherPlatformsUnchanged(t *testing.T) { + for _, platform := range []string{PlatformAnthropic, PlatformGemini} { + t.Run(platform, func(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ID: 506, Platform: platform, Type: AccountTypeAPIKey} + + shouldDisable := svc.HandleUpstreamError( + context.Background(), account, http.StatusForbidden, http.Header{}, []byte(openAI403HTMLBody), + ) + + require.True(t, shouldDisable) + require.Equal(t, 1, repo.setErrorCalls, "其他平台保持原有 SetError 行为") + }) + } +} + +func TestIsHTMLResponse(t *testing.T) { + cases := []struct { + name string + body string + want bool + }{ + {"doctype_lower", "", true}, + {"doctype_upper", "", true}, + {"bare_html", "", true}, + {"leading_whitespace", "\n\n ", true}, + {"json_error", `{"error":{"message":"forbidden"}}`, false}, + {"plain_text", "Forbidden", false}, + {"empty", "", false}, + // XML/SVG 之类不是 HTML,不在放行范围内。 + {"xml_declaration", ``, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, isHTMLResponse([]byte(tc.body))) + }) + } +}