Merge pull request #6434 from swjturay/codex/upstream-client-disconnect-drain

fix(openai-ws): drain upstream after client disconnect
This commit is contained in:
Wesley Liddick
2026-09-08 20:42:59 +08:00
committed by GitHub
4 changed files with 317 additions and 27 deletions
@@ -0,0 +1,25 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestOpenAIEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil).WithContext(ctx)
cancel()
h := &OpenAIGatewayHandler{}
require.False(t, h.ensureForwardErrorResponse(c, true))
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
require.Empty(t, recorder.Body.String())
}
@@ -835,6 +835,22 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
})
}
if err != nil {
if result != nil && result.ClientDisconnect {
reqLog.Info("openai.client_disconnected",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
submitResponsesUsage(result)
return
}
if failoverClientGone(c) {
reqLog.Info("openai.client_disconnected",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
submitResponsesUsage(result)
return
}
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai.forward_partial_error_with_image_result",
zap.Int64("account_id", account.ID),
@@ -3528,6 +3544,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
if c == nil || c.Writer == nil {
return false
}
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
failoverClientGone(c)
return false
}
// 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。
compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c)
if compactKeepaliveCommitted {
@@ -0,0 +1,184 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure
// reproduces the HTTP/SSE ingress bug: the client cancels after partial output,
// while the upstream WS still has a terminal event available for usage billing.
func TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
writer := &cancelOnFirstWriteResponseWriter{cancel: cancel}
c, _ := gin.CreateTestContext(writer)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx)
c.Request.Header.Set("User-Agent", "unit-test-agent/1.0")
captureConn := &openAIWSCancelSafeConn{openAIWSCaptureConn: &openAIWSCaptureConn{
events: [][]byte{
[]byte(`{"type":"response.created","response":{"id":"resp_cancel_1","model":"gpt-5.5"}}`),
[]byte(`{"type":"response.output_text.delta","delta":"partial"}`),
[]byte(`{"type":"response.completed","response":{"id":"resp_cancel_1","model":"gpt-5.5","usage":{"input_tokens":3,"output_tokens":5}}}`),
},
readDelays: []time.Duration{0, 0, 50 * time.Millisecond},
}}
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
pool := newOpenAIWSConnPool(cfg)
pool.setClientDialerForTest(&openAIWSClientConnCancelDialer{conn: captureConn})
svc := &OpenAIGatewayService{
cfg: cfg,
httpUpstream: &httpUpstreamRecorder{},
cache: &stubGatewayCache{},
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
toolCorrector: NewCodexToolCorrector(),
openaiWSPool: pool,
}
account := &Account{
ID: 9101,
Name: "openai-ws-client-cancel",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{"api_key": "sk-test"},
Extra: map[string]any{
"openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool,
},
}
result, err := svc.Forward(ctx, c, account, []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"input_text","text":"hello"}]}`))
require.NoError(t, err, "client cancellation must not surface as an upstream failure")
require.NotNil(t, result)
require.True(t, result.ClientDisconnect)
require.Equal(t, "resp_cancel_1", result.RequestID)
require.Equal(t, 3, result.Usage.InputTokens)
require.Equal(t, 5, result.Usage.OutputTokens)
require.NotContains(t, writer.body.String(), "response.failed")
}
type cancelOnFirstWriteResponseWriter struct {
mu sync.Mutex
header http.Header
body bytes.Buffer
cancel context.CancelFunc
writes int
status int
}
func (w *cancelOnFirstWriteResponseWriter) Header() http.Header {
if w.header == nil {
w.header = make(http.Header)
}
return w.header
}
func (w *cancelOnFirstWriteResponseWriter) WriteHeader(status int) {
w.status = status
}
func (w *cancelOnFirstWriteResponseWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.writes == 0 && w.cancel != nil {
w.cancel()
}
w.writes++
return w.body.Write(p)
}
func (w *cancelOnFirstWriteResponseWriter) Flush() {}
type openAIWSClientConnCancelDialer struct {
conn openAIWSClientConn
}
func (d *openAIWSClientConnCancelDialer) Dial(
ctx context.Context,
wsURL string,
headers http.Header,
proxyURL string,
) (openAIWSClientConn, int, http.Header, error) {
return d.conn, 0, nil, nil
}
// openAIWSCancelSafeConn leaves a delayed event queued when its read context is
// canceled, matching websocket frame semantics and allowing the drain read to
// consume that event with a detached context.
type openAIWSCancelSafeConn struct {
*openAIWSCaptureConn
}
func (c *openAIWSCancelSafeConn) ReadMessage(ctx context.Context) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, errOpenAIWSConnClosed
}
if len(c.events) == 0 {
c.mu.Unlock()
return nil, io.EOF
}
delay := time.Duration(0)
if len(c.readDelays) > 0 {
delay = c.readDelays[0]
}
event := c.events[0]
c.mu.Unlock()
if delay > 0 {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
}
}
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil, errOpenAIWSConnClosed
}
if len(c.events) == 0 {
return nil, io.EOF
}
c.events = c.events[1:]
if len(c.readDelays) > 0 {
c.readDelays = c.readDelays[1:]
}
return event, nil
}
@@ -374,6 +374,59 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
firstEventType := ""
lastEventType := ""
upstreamTerminalEvent := ""
clientDisconnected := false
clientDisconnectDrainStartedAt := time.Time{}
readTimeout := s.openAIWSReadTimeout()
upstreamReadCtx := ctx
upstreamReadDetached := false
clientRequestCanceled := func() bool {
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
}
markClientDisconnected := func(cause string) {
if clientDisconnected {
return
}
clientDisconnected = true
clientDisconnectDrainStartedAt = time.Now()
if !upstreamReadDetached {
upstreamReadCtx = context.WithoutCancel(ctx)
upstreamReadDetached = true
}
logOpenAIWSModeInfo(
"client_disconnected account_id=%d conn_id=%s cause=%s events=%d token_events=%d",
account.ID,
connID,
cause,
eventCount,
tokenEventCount,
)
}
markClientRequestCanceled := func() {
if clientRequestCanceled() {
markClientDisconnected("request_context_canceled")
}
}
resultWithUsage := func() *OpenAIForwardResult {
return &OpenAIForwardResult{
RequestID: responseID,
ResponseID: responseID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
UpstreamResponseModel: responseModelObserver.Model(),
UpstreamResponseModelConflict: responseModelObserver.Conflict(),
UpstreamResponseServiceTier: responseModelObserver.ServiceTier(),
ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)),
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
ResponseHeaders: lease.HandshakeHeaders(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnected,
}
}
var flusher http.Flusher
if reqStream {
@@ -392,7 +445,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
flusher = f
}
clientDisconnected := false
flushBatchSize := s.openAIWSEventFlushBatchSize()
flushInterval := s.openAIWSEventFlushInterval()
pendingFlushEvents := 0
@@ -425,7 +477,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
flushStreamWriter(forceFlush)
return
}
clientDisconnected = true
markClientDisconnected("downstream_write_error")
logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode] client disconnected, continue draining upstream: account=%d", account.ID)
}
flushBufferedStreamEvents := func(reason string) {
@@ -452,17 +504,32 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}
}
readTimeout := s.openAIWSReadTimeout()
// Keep per-read timeouts unchanged for connected clients. Once a client
// disconnects, use the same timeout as a bounded total drain budget.
var pendingJSONDocuments [][]byte
readLoop:
for {
markClientRequestCanceled()
var message []byte
var readErr error
readUsedDetachedContext := upstreamReadDetached
if len(pendingJSONDocuments) > 0 {
message = pendingJSONDocuments[0]
pendingJSONDocuments = pendingJSONDocuments[1:]
} else {
message, readErr = lease.ReadMessageWithContextTimeout(ctx, readTimeout)
currentReadTimeout := readTimeout
if clientDisconnected && !clientDisconnectDrainStartedAt.IsZero() {
remaining := readTimeout - time.Since(clientDisconnectDrainStartedAt)
if remaining <= 0 {
lease.MarkBroken()
break readLoop
}
if remaining < currentReadTimeout {
currentReadTimeout = remaining
}
}
message, readErr = lease.ReadMessageWithContextTimeout(upstreamReadCtx, currentReadTimeout)
if readErr == nil {
if documents, repaired := splitOpenAIConcatenatedJSONDocuments(message); repaired {
logOpenAIWSModeInfo(
@@ -477,6 +544,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}
}
}
markClientRequestCanceled()
if readErr == nil && !json.Valid(message) {
eventType, _, _ := parseOpenAIWSEventEnvelope(message)
if eventType == "" {
@@ -515,12 +583,15 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen),
truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen),
)
if clientDisconnected {
if !readUsedDetachedContext && errors.Is(readErr, context.Canceled) && clientRequestCanceled() {
continue
}
break
}
if !wroteDownstream {
return nil, wrapOpenAIWSFallback(classifyOpenAIWSReadFallbackReason(readErr), readErr)
}
if clientDisconnected {
break
}
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "")
return nil, fmt.Errorf("openai ws read event: %w", readErr)
}
@@ -701,7 +772,13 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}
}
if clientDisconnected && terminalEventCount == 0 {
return resultWithUsage(), fmt.Errorf("openai ws stream incomplete after client disconnect: %w", context.Canceled)
}
if !reqStream {
if clientDisconnected {
return resultWithUsage(), nil
}
if len(finalResponse) == 0 {
logOpenAIWSModeInfo(
"missing_final_response account_id=%d conn_id=%s events=%d token_events=%d terminal_events=%d wrote_downstream=%v",
@@ -763,26 +840,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
clientDisconnected,
)
return &OpenAIForwardResult{
RequestID: responseID,
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
UpstreamResponseModel: responseModelObserver.Model(),
UpstreamResponseModelConflict: responseModelObserver.Conflict(),
UpstreamResponseServiceTier: responseModelObserver.ServiceTier(),
ImageCount: imageCounter.Count(),
ImageOutputSizes: imageCounter.Sizes(),
ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)),
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
RequestedReasoningEffort: CanonicalRequestedReasoningEffortFromReqBody(reqBody, originalModel, mappedModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
ResponseHeaders: lease.HandshakeHeaders(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}, nil
result := resultWithUsage()
result.ImageCount = imageCounter.Count()
result.ImageOutputSizes = imageCounter.Sizes()
return result, nil
}
// ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。