Files
sub2api/backend/internal/service/client_disconnect_status_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

94 lines
3.4 KiB
Go

//go:build unit
package service
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// 三条传输层失败处理的共同契约:请求 context 已取消(客户端断开)时不记 Ops 上游错误事件;
// 请求 context 仍存活时即便错误是 context.Canceled 也照常记录,不归为客户端断开。
func TestTransportErrorHandlers_ClientDisconnectRecordsNoOpsEvent(t *testing.T) {
account := &Account{ID: 9, Name: "acc", Platform: PlatformAnthropic}
clientErr := &url.Error{Op: "Post", URL: "https://upstream.example/v1/messages", Err: context.Canceled}
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
handlers := []struct {
name string
handle func(ctx context.Context, c *gin.Context) error
}{
{"anthropic", func(ctx context.Context, c *gin.Context) error {
s := &GatewayService{accountRepo: &transportTempUnschedRepoStub{}}
return s.handleUpstreamTransportError(ctx, c, account, clientErr, OpsUpstreamErrorEvent{})
}},
{"gemini", func(ctx context.Context, c *gin.Context) error {
s := &GeminiMessagesCompatService{accountRepo: &transportTempUnschedRepoStub{}}
return s.handleUpstreamTransportError(ctx, c, account, clientErr)
}},
{"openai", func(ctx context.Context, c *gin.Context) error {
s := &OpenAIGatewayService{accountRepo: &openaiTransportAccountRepoStub{}}
return s.handleOpenAIUpstreamTransportError(ctx, c, account, clientErr, false)
}},
}
for _, h := range handlers {
t.Run(h.name+"/client disconnected", func(t *testing.T) {
c := newTransportErrorTestGin(t)
err := h.handle(canceledCtx, c)
require.ErrorIs(t, err, context.Canceled)
_, recorded := c.Get(OpsUpstreamErrorsKey)
require.False(t, recorded, "客户端断开不是上游故障")
_, messageSet := c.Get(OpsUpstreamErrorMessageKey)
require.False(t, messageSet)
})
t.Run(h.name+"/request context alive", func(t *testing.T) {
c := newTransportErrorTestGin(t)
err := h.handle(context.Background(), c)
require.ErrorIs(t, err, context.Canceled)
raw, recorded := c.Get(OpsUpstreamErrorsKey)
require.True(t, recorded)
require.Len(t, raw.([]*OpsUpstreamErrorEvent), 1)
})
}
}
func TestAntigravityCompatTransportError_ClientDisconnectWrites499(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
ctx, cancel := context.WithCancel(context.Background())
cancel()
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
err := (&AntigravityGatewayService{}).handleAntigravityCompatTransportError(c, context.Canceled)
require.Error(t, err)
require.Equal(t, antigravityStatusClientClosed, rec.Code)
require.Equal(t, "client_disconnected", gjson.Get(rec.Body.String(), "error.type").String())
}
func TestAntigravityWriteGoogleError_ClientClosedStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", nil)
_ = (&AntigravityGatewayService{}).writeGoogleError(c, antigravityStatusClientClosed, "Client disconnected before upstream response")
require.Equal(t, antigravityStatusClientClosed, rec.Code)
require.Equal(t, "CANCELLED", gjson.Get(rec.Body.String(), "error.status").String())
}