Files
sub2api/backend/internal/handler/gemini_client_cancel_test.go
T
feeeei f4f8ff04db fix(gateway): 客户端断开统一标记 499,不再被记成 502/200 上游错误
客户端在响应提交前断开时,只有排队/换号阶段和 OpenAI 的兜底写入会标 499。
Claude 非流式与 Gemini 的 Chat Completions / Messages / Responses 兼容入口
在等待上游时断开,由 GatewayHandler.ensureForwardErrorResponse 补写 502;
Gemini 原生入口什么都不写,落成 200 并被 ops 记成一条 provider 的
"Recovered upstream error";Antigravity 回写 502。这些行都按上游失败计入
SLA 与上游错误数。

- GatewayHandler.ensureForwardErrorResponse 补客户端断开检查,与 OpenAI 版一致
- GatewayHandler 的 Messages / ChatCompletions / Responses 与 Gemini 原生入口:
  首次选号失败时先判断客户端断开;ChatCompletions / Responses 循环开头改用
  failoverClientGone,不再直接 return 落成 200
- Gemini 原生入口:forward 失败时补 499 标记;并发槽错误改走
  concurrencyErrorResponse 统一映射(断开 499、排队满/超时 429、其余 503)
- Antigravity 上游响应前断开回写 499;googleapi 与 Antigravity 的 Google 错误
  格式把 499 映射为 CANCELLED
- OpenAI alpha search 非 failover 错误分支补断开检查
- Claude / Gemini / OpenAI 传输层错误:请求 context 已取消时不记 ops 上游事件
- ops:IgnoreContextCanceled 开启时跳过没有上游错误上下文的 499;带上游错误的
  499(上游失败后客户端没等到换号结果就离开)仍按上游失败落库

计费不变:Gemini 流式中途断开的用量处理不在本次范围。新增 handler/service
单测覆盖各入口的断开场景与 ops 过滤规则。
2026-09-26 17:13:10 +08:00

131 lines
4.6 KiB
Go

//go:build unit
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// clientCancelUpstream 模拟上游尚未响应时客户端断开:Do 期间取消请求 context,
// 并按 net/http 的形态返回包裹 context.Canceled 的 *url.Error。
type clientCancelUpstream struct {
service.HTTPUpstream
cancel context.CancelFunc
calls atomic.Int32
}
func (u *clientCancelUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
u.calls.Add(1)
u.cancel()
return nil, &url.Error{Op: "Post", URL: req.URL.String(), Err: context.Canceled}
}
type geminiClientCancelFixture struct {
handler *GatewayHandler
group *service.Group
apiKey *service.APIKey
upstream *clientCancelUpstream
ctx context.Context
}
func newGeminiClientCancelFixture(t *testing.T) *geminiClientCancelFixture {
t.Helper()
groupID := int64(9200)
accountID := int64(9201)
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive}
account := &service.Account{
ID: accountID,
Platform: service.PlatformGemini,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "test-key"},
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 1,
AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
}
h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account})
t.Cleanup(cleanup)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
upstream := &clientCancelUpstream{cancel: cancel}
h.geminiCompatService = service.NewGeminiMessagesCompatService(nil, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})
apiKey := &service.APIKey{
ID: 9202, UserID: 9203, GroupID: &groupID, Group: group, Status: service.StatusActive,
User: &service.User{ID: 9203, Concurrency: 10, Balance: 100},
}
return &geminiClientCancelFixture{handler: h, group: group, apiKey: apiKey, upstream: upstream, ctx: ctx}
}
// serve 经 ops 错误日志中间件执行请求,返回响应与 ops 队列中的条目数。
func (f *geminiClientCancelFixture) serve(t *testing.T, route, path, body string, call func(*gin.Context)) (*httptest.ResponseRecorder, int64) {
t.Helper()
setupOpsErrorLogTestQueue(t, 4)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST(route, func(c *gin.Context) {
c.Set(string(middleware.ContextKeyAPIKey), f.apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: f.apiKey.UserID, Concurrency: 10})
call(c)
})
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req = req.WithContext(context.WithValue(f.ctx, ctxkey.Group, f.group))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec, OpsErrorLogQueueLength()
}
// 原生入口:上游响应前客户端断开,响应未提交,标记 499;纯客户端取消不落 ops 错误日志。
func TestGeminiV1BetaModels_ClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
gin.SetMode(gin.TestMode)
f := newGeminiClientCancelFixture(t)
rec, opsQueued := f.serve(t,
"/v1beta/models/*modelAction",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`,
f.handler.GeminiV1BetaModels,
)
require.Equal(t, int32(1), f.upstream.calls.Load())
require.Equal(t, statusClientClosedRequest, rec.Code)
require.Zero(t, rec.Body.Len())
require.Zero(t, opsQueued)
}
// Chat Completions 兼容入口(Gemini 分组):同一场景标记 499,不再补写 502。
func TestGatewayChatCompletions_GeminiClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
gin.SetMode(gin.TestMode)
f := newGeminiClientCancelFixture(t)
rec, opsQueued := f.serve(t,
"/v1/chat/completions",
"/v1/chat/completions",
`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"stream":true}`,
f.handler.ChatCompletions,
)
require.Equal(t, int32(1), f.upstream.calls.Load())
require.Equal(t, statusClientClosedRequest, rec.Code)
require.Zero(t, rec.Body.Len())
require.Zero(t, opsQueued)
}