mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
客户端在响应提交前断开时,只有排队/换号阶段和 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 过滤规则。
131 lines
4.6 KiB
Go
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)
|
|
}
|