Merge pull request #6708 from r266-tech/r266/fix-ws-turn-rate-limit-6679

fix(openai-ws): recover later passthrough turns from account quota exhaustion
This commit is contained in:
Wesley Liddick
2026-09-07 18:44:18 +08:00
committed by GitHub
4 changed files with 143 additions and 3 deletions
@@ -102,6 +102,7 @@ type RelayTraceEvent struct {
type relayState struct {
usage Usage
turnUsage Usage
turnWroteDownstream atomic.Bool
requestModelMu sync.RWMutex
requestModel string
pendingTurnStart atomic.Pointer[time.Time]
@@ -204,7 +205,8 @@ func Relay(
return upstreamConn.WriteFrame(writeCtx, msgType, payload)
}
writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error {
if isClientResponseCreateFrame(msgType, payload) {
isResponseCreate := isClientResponseCreateFrame(msgType, payload)
if isResponseCreate {
state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String()))
turnStartedAt := time.Time{}
if options.TakeNextTurnStartedAt != nil {
@@ -214,8 +216,18 @@ func Relay(
turnStartedAt = nowFn()
}
state.setPendingTurnStartedAt(turnStartedAt)
// The policy-enforcing client connection has accepted this turn.
// Reset before the write so an immediate upstream response cannot race
// with the transport returning from WriteFrame.
state.turnWroteDownstream.Store(false)
}
return writeUpstream(msgType, payload)
err := writeUpstream(msgType, payload)
if err != nil && isResponseCreate {
// The relay exits on this error, but retain the previous turn's state
// for accurate diagnostics while the two relay goroutines settle.
state.turnWroteDownstream.Store(true)
}
return err
}
writeClient := func(msgType coderws.MessageType, payload []byte) error {
// 下行写超时故意不挂在 relayCtx 上:coder/websocket 在已武装的 write
@@ -559,7 +571,11 @@ func runUpstreamToClient(
}
markActivity()
if beforeWriteClient != nil {
if err := beforeWriteClient(msgType, payload, wroteDownstream); err != nil {
wroteDownstreamInTurn := wroteDownstream
if state != nil {
wroteDownstreamInTurn = state.turnWroteDownstream.Load()
}
if err := beforeWriteClient(msgType, payload, wroteDownstreamInTurn); err != nil {
emitRelayTrace(onTrace, RelayTraceEvent{
Stage: "upstream_message_rejected",
Direction: "upstream_to_client",
@@ -630,6 +646,9 @@ func runUpstreamToClient(
return
}
wroteDownstream = true
if state != nil {
state.turnWroteDownstream.Store(true)
}
if afterWriteClient != nil {
afterWriteClient(msgType, payload)
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -903,6 +904,63 @@ func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBound
}
}
func TestRelay_BeforeWriteClientTracksDownstreamPerTurn(t *testing.T) {
t.Parallel()
clientConn := newPassthroughTestFrameConn(nil, false)
upstreamConn := newPassthroughTestFrameConn(nil, false)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wroteStates := make(chan bool, 3)
done := make(chan *RelayExit, 1)
stopErr := errors.New("stop after second-turn error")
go func() {
_, relayExit := Relay(
ctx,
clientConn,
upstreamConn,
[]byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
RelayOptions{BeforeWriteClient: func(_ coderws.MessageType, payload []byte, wroteDownstream bool) error {
wroteStates <- wroteDownstream
if strings.Contains(string(payload), `"type":"error"`) {
return stopErr
}
return nil
}},
)
done <- relayExit
}()
require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond)
upstreamConn.readCh <- passthroughTestFrame{
msgType: coderws.MessageText,
payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`),
}
require.False(t, <-wroteStates)
require.Eventually(t, func() bool { return len(clientConn.Writes()) == 1 }, time.Second, time.Millisecond)
clientConn.readCh <- passthroughTestFrame{
msgType: coderws.MessageText,
payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`),
}
require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond)
upstreamConn.readCh <- passthroughTestFrame{
msgType: coderws.MessageText,
payload: []byte(`{"type":"error","error":{"type":"usage_limit_reached"}}`),
}
require.False(t, <-wroteStates, "the next turn must not inherit the first turn's downstream write")
select {
case relayExit := <-done:
require.NotNil(t, relayExit)
require.ErrorIs(t, relayExit.Err, stopErr)
require.True(t, relayExit.WroteDownstream, "connection-wide diagnostics must retain prior output")
case <-time.After(time.Second):
t.Fatal("relay did not stop after the rejected second-turn event")
}
}
func TestRelay_BinaryFramePassthrough(t *testing.T) {
t.Parallel()
@@ -1304,6 +1304,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen),
truncateOpenAIWSLogValue(errMsgRaw, openAIWSLogValueMaxLen),
)
if completedTurns.Load() > 0 {
return NewOpenAIWSClientCloseError(
coderws.StatusTryAgainLater,
"upstream rate limit exceeded; please reconnect",
errors.New("later passthrough turn was rate limited before output"),
)
}
return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, payload, errMsgRaw)
},
OnTrace: func(event openaiwsv2.RelayTraceEvent) {
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -210,6 +211,61 @@ func startPassthroughLifecycleServerWithHooks(
return server, serverErr
}
func TestPassthroughLifecycle_LaterTurnPreOutputRateLimitRequestsReconnect(t *testing.T) {
gin.SetMode(gin.TestMode)
controlCtx, cancelControl := context.WithCancelCause(context.Background())
defer cancelControl(context.Canceled)
upstream := newStagedPassthroughConn()
account := passthroughLifecycleAccount()
repo := &openAIWSRateLimitSignalRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{*account}}}
svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream)
svc.accountRepo = repo
svc.rateLimitService = &RateLimitService{accountRepo: repo}
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, svc, account)
defer server.Close()
clientConn := dialPassthroughLifecycleClient(t, server)
defer func() { _ = clientConn.CloseNow() }()
firstRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second)
require.Equal(t, "response.create", gjson.GetBytes(firstRequest, "type").String())
upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
completed, err := readPassthroughLifecycleFrame(t, clientConn, time.Second)
require.NoError(t, err)
require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String())
writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
cancelWrite()
require.NoError(t, err)
secondRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second)
require.Equal(t, "response.create", gjson.GetBytes(secondRequest, "type").String())
resetAt := time.Now().Add(90 * time.Minute).Unix()
upstream.Send(fmt.Sprintf(`{"type":"error","error":{"code":"rate_limit_exceeded","type":"usage_limit_reached","message":"The usage limit has been reached","resets_at":%d}}`, resetAt))
_, err = readPassthroughLifecycleFrame(t, clientConn, time.Second)
var websocketCloseErr coderws.CloseError
require.ErrorAs(t, err, &websocketCloseErr)
require.Equal(t, coderws.StatusTryAgainLater, websocketCloseErr.Code)
require.Equal(t, "upstream rate limit exceeded; please reconnect", websocketCloseErr.Reason)
require.Len(t, repo.rateLimitCalls, 1)
require.WithinDuration(t, time.Unix(resetAt, 0), repo.rateLimitCalls[0], 2*time.Second)
select {
case err := <-serverErr:
var closeErr *OpenAIWSClientCloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusTryAgainLater, closeErr.StatusCode())
case <-time.After(time.Second):
t.Fatal("later-turn rate limit did not terminate passthrough")
}
select {
case replay := <-upstream.writes:
t.Fatalf("later-turn reconnect must not replay the retained first request: %s", replay)
default:
}
}
func TestPassthroughLifecycle_CyberTerminalEventsMarkBeforeAfterTurn(t *testing.T) {
gin.SetMode(gin.TestMode)