Files
sub2api/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

592 lines
24 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
type stagedPassthroughFrame struct {
messageType coderws.MessageType
payload []byte
}
type stagedPassthroughConn struct {
frames chan stagedPassthroughFrame
writes chan []byte
closed chan struct{}
closeOnce sync.Once
}
func newStagedPassthroughConn() *stagedPassthroughConn {
return &stagedPassthroughConn{
frames: make(chan stagedPassthroughFrame, 4),
writes: make(chan []byte, 4),
closed: make(chan struct{}),
}
}
func (c *stagedPassthroughConn) Send(payload string) {
c.frames <- stagedPassthroughFrame{messageType: coderws.MessageText, payload: []byte(payload)}
}
func (c *stagedPassthroughConn) WriteJSON(context.Context, any) error { return nil }
func (c *stagedPassthroughConn) ReadMessage(ctx context.Context) ([]byte, error) {
_, payload, err := c.ReadFrame(ctx)
return payload, err
}
func (c *stagedPassthroughConn) Ping(context.Context) error { return nil }
func (c *stagedPassthroughConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
return coderws.MessageText, nil, ctx.Err()
case <-c.closed:
return coderws.MessageText, nil, errOpenAIWSConnClosed
case frame := <-c.frames:
return frame.messageType, append([]byte(nil), frame.payload...), nil
}
}
func (c *stagedPassthroughConn) WriteFrame(ctx context.Context, _ coderws.MessageType, payload []byte) error {
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-c.closed:
return errOpenAIWSConnClosed
default:
}
var parsed any
if err := json.Unmarshal(payload, &parsed); err != nil {
return err
}
select {
case c.writes <- append([]byte(nil), payload...):
case <-ctx.Done():
return ctx.Err()
case <-c.closed:
return errOpenAIWSConnClosed
}
return nil
}
func (c *stagedPassthroughConn) Close() error {
c.closeOnce.Do(func() { close(c.closed) })
return nil
}
type stagedPassthroughDialer struct {
conn openAIWSClientConn
}
func (d *stagedPassthroughDialer) Dial(context.Context, string, http.Header, string) (openAIWSClientConn, int, http.Header, error) {
return d.conn, http.StatusSwitchingProtocols, http.Header{}, nil
}
func newPassthroughLifecycleService(cfg *config.Config, upstream *stagedPassthroughConn) *OpenAIGatewayService {
return &OpenAIGatewayService{
cfg: cfg,
httpUpstream: &httpUpstreamRecorder{},
cache: &stubGatewayCache{},
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
toolCorrector: NewCodexToolCorrector(),
openaiWSPassthroughDialer: &stagedPassthroughDialer{conn: upstream},
}
}
func passthroughLifecycleConfig() *config.Config {
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 1
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.IngressInterTurnIdleTimeoutSeconds = 1
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 1
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
return cfg
}
func passthroughLifecycleAccount() *Account {
return &Account{
ID: 901,
Name: "passthrough-lifecycle",
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": OpenAIWSIngressModePassthrough,
},
}
}
func startPassthroughLifecycleServer(
t *testing.T,
controlCtx context.Context,
svc *OpenAIGatewayService,
account *Account,
) (*httptest.Server, <-chan error) {
t.Helper()
serverErr := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
if err != nil {
serverErr <- err
return
}
defer func() { _ = conn.CloseNow() }()
msgType, firstMessage, err := ReadOpenAIWSClientMessage(
controlCtx,
conn,
3*time.Second,
coderws.StatusPolicyViolation,
"missing first response.create message",
)
if err != nil {
serverErr <- err
return
}
if msgType != coderws.MessageText {
serverErr <- errors.New("first message was not text")
return
}
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := r.Clone(controlCtx)
req.Header = req.Header.Clone()
ginCtx.Request = req
serverErr <- svc.ProxyResponsesWebSocketFromClient(controlCtx, ginCtx, conn, account, "sk-test", firstMessage, nil)
}))
return server, serverErr
}
func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *coderws.Conn {
t.Helper()
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil)
cancelDial()
require.NoError(t, err)
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
cancelWrite()
require.NoError(t, err)
return clientConn
}
func readPassthroughLifecycleFrame(t *testing.T, clientConn *coderws.Conn, timeout time.Duration) ([]byte, error) {
t.Helper()
readCtx, cancelRead := context.WithTimeout(context.Background(), timeout)
_, payload, err := clientConn.Read(readCtx)
cancelRead()
return payload, err
}
func requirePassthroughUpstreamWrite(t *testing.T, upstream *stagedPassthroughConn, timeout time.Duration) []byte {
t.Helper()
select {
case payload := <-upstream.writes:
return payload
case <-time.After(timeout):
t.Fatal("passthrough request was not forwarded upstream")
return nil
}
}
func TestOpenAIWSPassthroughTurnLifecycle_SerializesTerminalCommitAndNextTurn(t *testing.T) {
clientFrameConn := &openAIWSClientFrameConn{interTurnStarted: make(chan struct{}, 1)}
clientFrameConn.markTurnCompleted()
lifecycle := newOpenAIWSPassthroughTurnLifecycle(true)
lifecycle.beginTerminalWrite()
admitted := make(chan bool, 1)
go func() {
admitted <- lifecycle.beginResponseCreate(clientFrameConn.markTurnStarted)
}()
select {
case <-admitted:
t.Fatal("next response.create was admitted before terminal commit completed")
case <-time.After(50 * time.Millisecond):
}
lifecycle.finishTerminalWrite(true, clientFrameConn.markTurnCompleted)
select {
case ok := <-admitted:
require.True(t, ok)
case <-time.After(time.Second):
t.Fatal("next response.create remained blocked after terminal commit")
}
require.False(t, clientFrameConn.waitingForNextTurn.Load(), "accepted next turn must win over terminal idle state")
lifecycle = newOpenAIWSPassthroughTurnLifecycle(true)
lifecycle.beginTerminalWrite()
admitted = make(chan bool, 1)
go func() {
admitted <- lifecycle.beginResponseCreate(nil)
}()
lifecycle.finishTerminalWrite(false, func() {
t.Error("failed terminal write must not commit idle state")
})
require.False(t, <-admitted, "failed terminal write must keep the current turn in flight")
}
func TestPassthroughLifecycle_LeaseLossSendsRetryClose(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.created","response":{"id":"resp_lease","model":"gpt-5.1"}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "response.created", gjson.GetBytes(event, "type").String())
cancelControl(ErrOpenAIWSIngressLeaseLost)
_, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
var closeErr coderws.CloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusTryAgainLater, closeErr.Code)
require.Equal(t, "websocket ingress capacity lease lost; please reconnect", closeErr.Reason)
select {
case <-serverErr:
case <-time.After(3 * time.Second):
t.Fatal("passthrough lease-loss reader did not exit")
}
}
func TestPassthroughLifecycle_CompletedTurnStartsInterTurnIdle(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.completed","response":{"id":"resp_idle","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
_, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
var closeErr coderws.CloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusNormalClosure, closeErr.Code)
require.Equal(t, "websocket idle timeout", closeErr.Reason)
select {
case <-serverErr:
case <-time.After(3 * time.Second):
t.Fatal("passthrough idle reader did not exit")
}
}
func TestPassthroughLifecycle_ActiveTurnInactivityUsesReadTimeout(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.output_text.delta","response_id":"resp_active","delta":"hello"}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
delta, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "response.output_text.delta", gjson.GetBytes(delta, "type").String())
_, err = readPassthroughLifecycleFrame(t, clientConn, 2500*time.Millisecond)
var websocketCloseErr coderws.CloseError
require.ErrorAs(t, err, &websocketCloseErr)
require.Equal(t, coderws.StatusGoingAway, websocketCloseErr.Code)
require.Equal(t, "upstream websocket read timeout; please reconnect", websocketCloseErr.Reason)
select {
case err := <-serverErr:
var closeErr *OpenAIWSClientCloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusGoingAway, closeErr.StatusCode())
require.Equal(t, "upstream websocket read timeout; please reconnect", closeErr.Reason())
case <-time.After(2500 * time.Millisecond):
t.Fatal("passthrough active turn remained unbounded after upstream activity stopped")
}
}
func TestPassthroughLifecycle_PreambleAllowsPromptClientCancel(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
cfg := passthroughLifecycleConfig()
cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 3
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.created","response":{"id":"resp_cancel","model":"gpt-5.1"}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(cfg, upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String())
created, err := readPassthroughLifecycleFrame(t, clientConn, time.Second)
require.NoError(t, err)
require.Equal(t, "response.created", gjson.GetBytes(created, "type").String())
writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.cancel","response_id":"resp_cancel"}`))
cancelWrite()
require.NoError(t, err)
cancelFrame := requirePassthroughUpstreamWrite(t, upstream, 500*time.Millisecond)
require.Equal(t, "response.cancel", gjson.GetBytes(cancelFrame, "type").String())
require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done"))
select {
case <-serverErr:
case <-time.After(3 * time.Second):
t.Fatal("passthrough cancel test did not exit")
}
}
func TestPassthroughLifecycle_RejectsOverlappingResponseCreate(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
cfg := passthroughLifecycleConfig()
cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 3
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.created","response":{"id":"resp_overlap_first","model":"gpt-5.1"}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(cfg, upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String())
created, err := readPassthroughLifecycleFrame(t, clientConn, time.Second)
require.NoError(t, err)
require.Equal(t, "response.created", gjson.GetBytes(created, "type").String())
writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`))
cancelWrite()
require.NoError(t, err)
_, err = readPassthroughLifecycleFrame(t, clientConn, time.Second)
var websocketCloseErr coderws.CloseError
require.ErrorAs(t, err, &websocketCloseErr)
require.Equal(t, coderws.StatusPolicyViolation, websocketCloseErr.Code)
require.Equal(t, "overlapping response.create is not supported", websocketCloseErr.Reason)
select {
case err := <-serverErr:
var closeErr *OpenAIWSClientCloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode())
require.Equal(t, "overlapping response.create is not supported", closeErr.Reason())
case <-time.After(3 * time.Second):
t.Fatal("overlapping response.create did not terminate passthrough")
}
}
func TestPassthroughLifecycle_ActiveTurnActivityRefreshesReadTimeout(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.output_text.delta","response_id":"resp_active_refresh","delta":"one"}`)
go func() {
for _, event := range []string{
`{"type":"response.output_text.delta","response_id":"resp_active_refresh","delta":"two"}`,
`{"type":"response.output_text.delta","response_id":"resp_active_refresh","delta":"three"}`,
`{"type":"response.completed","response":{"id":"resp_active_refresh","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":3}}}`,
} {
timer := time.NewTimer(600 * time.Millisecond)
<-timer.C
timer.Stop()
upstream.Send(event)
}
}()
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
for _, wantType := range []string{
"response.output_text.delta",
"response.output_text.delta",
"response.output_text.delta",
"response.completed",
} {
frame, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, wantType, gjson.GetBytes(frame, "type").String())
}
require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done"))
select {
case <-serverErr:
case <-time.After(3 * time.Second):
t.Fatal("passthrough active-turn refresh test did not exit")
}
}
func TestPassthroughLifecycle_TerminalSwitchesToInterTurnIdleTimeout(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
cfg := passthroughLifecycleConfig()
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 1
cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = 2
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.completed","response":{"id":"resp_idle_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(cfg, upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String())
completed, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "resp_idle_first", gjson.GetBytes(completed, "response.id").String())
time.Sleep(1300 * time.Millisecond)
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_idle_first"}`))
cancelWrite()
require.NoError(t, err)
require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String())
upstream.Send(`{"type":"response.completed","response":{"id":"resp_idle_second","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
completed, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "resp_idle_second", gjson.GetBytes(completed, "response.id").String())
_, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
var websocketCloseErr coderws.CloseError
require.ErrorAs(t, err, &websocketCloseErr)
require.Equal(t, coderws.StatusNormalClosure, websocketCloseErr.Code)
require.Equal(t, "websocket idle timeout", websocketCloseErr.Reason)
select {
case err := <-serverErr:
var closeErr *OpenAIWSClientCloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusNormalClosure, closeErr.StatusCode())
require.Equal(t, "websocket idle timeout", closeErr.Reason())
case <-time.After(3 * time.Second):
t.Fatal("passthrough terminal turn did not use inter-turn idle timeout")
}
}
func TestPassthroughLifecycle_FirstOutputTimeoutRemainsBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
select {
case err := <-serverErr:
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode)
require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout")
case <-time.After(2500 * time.Millisecond):
t.Fatal("passthrough first output was left unbounded")
}
}
func TestPassthroughLifecycle_ResponseCreatedTimeoutClosesWithoutFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.created","response":{"id":"resp_preamble","model":"gpt-5.1"}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
created, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "response.created", gjson.GetBytes(created, "type").String())
_, err = readPassthroughLifecycleFrame(t, clientConn, 2500*time.Millisecond)
var websocketCloseErr coderws.CloseError
require.ErrorAs(t, err, &websocketCloseErr)
require.Equal(t, coderws.StatusGoingAway, websocketCloseErr.Code)
require.Equal(t, "upstream produced no semantic output; please reconnect", websocketCloseErr.Reason)
select {
case err := <-serverErr:
var failoverErr *UpstreamFailoverError
require.NotErrorAs(t, err, &failoverErr)
var closeErr *OpenAIWSClientCloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusGoingAway, closeErr.StatusCode())
require.Equal(t, "upstream produced no semantic output; please reconnect", closeErr.Reason())
case <-time.After(2500 * time.Millisecond):
t.Fatal("response.created timeout did not close the passthrough connection")
}
}
func TestPassthroughLifecycle_SecondTurnTimeoutIsNotFailoverSafe(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
completed, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String())
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_first"}`))
cancelWrite()
require.NoError(t, err)
upstream.Send(`{"type":"response.created","response":{"id":"resp_second","model":"gpt-5.1"}}`)
created, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
require.NoError(t, err)
require.Equal(t, "response.created", gjson.GetBytes(created, "type").String())
_, err = readPassthroughLifecycleFrame(t, clientConn, 2500*time.Millisecond)
var websocketCloseErr coderws.CloseError
require.ErrorAs(t, err, &websocketCloseErr)
require.Equal(t, coderws.StatusGoingAway, websocketCloseErr.Code)
require.Equal(t, "upstream produced no semantic output; please reconnect", websocketCloseErr.Reason)
select {
case err := <-serverErr:
var failoverErr *UpstreamFailoverError
require.NotErrorAs(t, err, &failoverErr, "handler must not replay the initial request on another account for a later-turn timeout")
var closeErr *OpenAIWSClientCloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusGoingAway, closeErr.StatusCode())
case <-time.After(2500 * time.Millisecond):
t.Fatal("second turn first semantic output was left unbounded")
}
}