Files
sub2api/backend/internal/service/openai_compact_sse_keepalive.go
T
HuntercodeT 60756c0ca4 fix(openai): emit pre-output SSE keepalive on the /v1/responses passthrough path
The passthrough streaming loop buffers leading events in `pendingLines` until
the first visible output, so a client receives **zero bytes** — not even the
HTTP response headers, since gin's ResponseWriter only commits them on the
first write — for as long as the upstream takes to produce its first visible
event.

The Forward path already recognises this exact hazard and solves it with a
downstream keepalive:

    // Track downstream writes separately from upstream reads: pre-output
    // failover can buffer response.created / response.in_progress, so
    // keepalive must be based on downstream idle time.

The passthrough path has the same buffering but never starts a keepalive.
Reasoning models routinely think for several minutes before emitting a visible
event, so intermediate proxies close the connection on idle timeout.

Production evidence from one deployment (12h window, gpt-5.6 family):

  * 44 `/v1/responses` requests produced their first visible output only after
    600–900 s, each carrying 30k–50k output tokens — the upstream had finished
    the work.
  * Every one of them was killed by an intermediate nginx `proxy_read_timeout`
    (600 s) and returned 504. The user received nothing.
  * Cutting early does not even save resources: the code deliberately keeps
    draining the upstream for usage after the client disconnects.

Fix: start the existing keepalive helper for the passthrough stream as well.

* `StartOpenAICompactSSEKeepalive` keeps its compact-marker gate; the gate-free
  body is extracted as `startOpenAISSEKeepalive` for callers that already know
  they are in an SSE context.
* The passthrough loop starts it when `gateway.stream_keepalive_interval > 0`
  and stops it immediately before its first real write, so the ResponseWriter
  is never shared.

Pre-output failover is unaffected: keepalive bytes are already excluded by
`OpenAICompactKeepaliveAdjustedWrittenSize`, which `openAIStreamClientOutputStarted`
on this very path already consults (#3887). No pendingLines and no
account-specific headers are ever emitted by a keepalive.

Behaviour is unchanged when `gateway.stream_keepalive_interval` is 0.
2026-08-28 10:19:43 +08:00

319 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bufio"
"errors"
"net"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// openAICompactSSEKeepaliveKey 存放 body-signal compact 请求的下游 SSE 心跳器。
const openAICompactSSEKeepaliveKey = "openai_compact_sse_keepalive"
// openAICompactSSEKeepalive 在 compact 上游 unary 等待期间向下游写 SSE 注释行
// 心跳。上游 /responses/compact 在模型处理期间不发送任何字节(大上下文可长达
// 数分钟),下游若经过反向代理(Nginx/Cloudflare Tunnel 等),零字节静默会触发
// 代理的空闲/读超时并掐断连接,Codex 只会盲目重连并重复消耗上游 compact
// 配额(#3887)。SSE 注释行在 eventsource 解析层被直接忽略,不会进入客户端
// 事件流。
//
// 首拍延迟一个 interval:绝大多数硬错误(鉴权/参数/限流)在此之前返回,仍走
// 原 JSON+状态码链路(Codex 按 HTTP 状态码重试);首拍之后状态码固化为 200,
// 后续错误由写回方降级为 response.failed 流内终止事件。
type openAICompactSSEKeepalive struct {
mu sync.Mutex
writer gin.ResponseWriter
started bool
stopped bool
// bytes 是心跳已写出的注释字节数。心跳不构成语义响应,handler 的
// "Forward 期间是否已写响应"判定(failover 放弃换号的依据)必须扣除
// 这部分字节,见 OpenAICompactKeepaliveAdjustedWrittenSize。
bytes int
stop chan struct{}
}
// StartOpenAICompactSSEKeepalive 为已标记 body-signal 客户端流式的 compact
// 请求启动下游心跳,返回幂等的停止函数。interval<=0 或请求未标记时为 no-op。
//
// 同时把 c.Writer 替换为 openAICompactKeepaliveWriter:请求 goroutine 的任何
// 响应构造都会先在心跳互斥锁下停拍,未被显式拦截的写回路径(如 Forward
// 内部的本地拒绝)也不会与心跳 goroutine 产生数据竞争或字节交错。
func StartOpenAICompactSSEKeepalive(c *gin.Context, interval time.Duration) func() {
if !openAICompactClientWantsStream(c) {
return func() {}
}
return startOpenAISSEKeepalive(c, interval)
}
// startOpenAISSEKeepalive 是不检查 compact 标记的内部入口,供【已经确定处于
// SSE 流式上下文】的调用方使用(例如 /v1/responses 透传:进入流式循环时上游
// 已返回 text/event-stream,SSE 响应头也已设好)。
//
// 心跳字节由 OpenAICompactKeepaliveAdjustedWrittenSize 统一排除,因此不会污染
// "是否已向客户端写出语义响应"的 failover 判定(见 #3887)。
func startOpenAISSEKeepalive(c *gin.Context, interval time.Duration) func() {
if c == nil || c.Writer == nil || interval <= 0 {
return func() {}
}
originalWriter := c.Writer
k := &openAICompactSSEKeepalive{
writer: originalWriter,
stop: make(chan struct{}),
}
c.Set(openAICompactSSEKeepaliveKey, k)
wrappedWriter := &openAICompactKeepaliveWriter{ResponseWriter: originalWriter, k: k}
c.Writer = wrappedWriter
var reqDone <-chan struct{}
if c.Request != nil {
reqDone = c.Request.Context().Done()
}
go func() {
timer := time.NewTimer(interval)
defer timer.Stop()
for {
select {
case <-k.stop:
return
case <-reqDone:
return
case <-timer.C:
}
if !k.beat() {
return
}
timer.Reset(interval)
}
}()
return func() {
k.Stop()
// Do not leave a pooled middleware writer reachable through the compact
// wrapper after the request finishes.
if current, ok := c.Writer.(*openAICompactKeepaliveWriter); ok && current == wrappedWriter {
c.Writer = originalWriter
}
}
}
// beat 在锁内提交(首次)响应头并写出一条 SSE 注释行;返回 false 表示心跳已
// 停止或下游写入失败,goroutine 应退出。
func (k *openAICompactSSEKeepalive) beat() bool {
k.mu.Lock()
defer k.mu.Unlock()
if k.stopped {
return false
}
if !k.started {
header := k.writer.Header()
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache")
header.Set("Connection", "keep-alive")
header.Set("X-Accel-Buffering", "no")
k.writer.WriteHeader(http.StatusOK)
k.started = true
}
n, err := k.writer.Write([]byte(": keepalive\n\n"))
k.bytes += n
if err != nil {
k.stopped = true
return false
}
k.writer.Flush()
return true
}
// Stop 停止心跳;幂等,可与写回路径并发调用。
func (k *openAICompactSSEKeepalive) Stop() {
k.mu.Lock()
k.markStoppedLocked()
k.mu.Unlock()
}
func (k *openAICompactSSEKeepalive) markStoppedLocked() {
if k.stopped {
return
}
k.stopped = true
close(k.stop)
}
// StopOpenAICompactSSEKeepaliveCommitted 停止当前请求的 compact 心跳(若有)
// 并报告响应头是否已被心跳提交为 200。写回方以此决定继续走原 JSON/状态码
// 链路,还是降级为流内终止事件。调用后不会再有心跳字节写出,且经由互斥锁
// 与心跳 goroutine 建立 happens-before,调用方可安全接管 ResponseWriter。
func StopOpenAICompactSSEKeepaliveCommitted(c *gin.Context) bool {
if c == nil {
return false
}
value, ok := c.Get(openAICompactSSEKeepaliveKey)
if !ok {
return false
}
k, ok := value.(*openAICompactSSEKeepalive)
if !ok || k == nil {
return false
}
k.mu.Lock()
k.markStoppedLocked()
committed := k.started
k.mu.Unlock()
return committed
}
// OpenAICompactKeepaliveAdjustedWrittenSize 返回排除 compact 心跳注释字节后
// 的响应已写字节数;无心跳的请求等价于 c.Writer.Size()。心跳字节不构成语义
// 响应——handler 以"Forward 前后 Size 是否变化"判定是否已向客户端写出响应
// (变化则放弃 failover 换号),该判定不得被心跳污染,否则 compact 请求
// 一旦在上游等待期间发过心跳,上游 429/5xx 就不再换号(#3887 加固审计)。
// 仅心跳字节时归一化为 -1(gin 的"未写出"哨兵值),与提交前的快照可比。
func OpenAICompactKeepaliveAdjustedWrittenSize(c *gin.Context) int {
if c == nil || c.Writer == nil {
return -1
}
streamKeepaliveBytes := 0
if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok {
streamKeepaliveBytes, _ = value.(int)
}
size := c.Writer.Size()
compactKeepaliveBytes := 0
if value, ok := c.Get(openAICompactSSEKeepaliveKey); ok {
if k, valid := value.(*openAICompactSSEKeepalive); valid && k != nil {
k.mu.Lock()
size = k.writer.Size()
compactKeepaliveBytes = k.bytes
k.mu.Unlock()
}
}
if size < 0 {
return size
}
keepaliveBytes := compactKeepaliveBytes + streamKeepaliveBytes
if keepaliveBytes <= 0 {
return size
}
if real := size - keepaliveBytes; real > 0 {
return real
}
return -1
}
// openAICompactKeepaliveWriter 包装 gin.ResponseWriter:写侧方法先停拍心跳
// (互斥锁下建立 happens-before),读侧方法仅加锁不停拍——热路径的状态读取
// (如 Forward 前的 Size 快照)不能误杀心跳。心跳 goroutine 直接写内层
// writer(k.writer),不经过本包装器,不会递归。
type openAICompactKeepaliveWriter struct {
gin.ResponseWriter
k *openAICompactSSEKeepalive
}
// suspend 停拍心跳;幂等。任何响应构造(含 Header 访问——写响应必先操作
// 响应头)都视为请求侧接管 ResponseWriter。
func (w *openAICompactKeepaliveWriter) suspend() {
if w.k == nil {
return
}
w.k.Stop()
}
func (w *openAICompactKeepaliveWriter) Header() http.Header {
w.suspend()
if w.ResponseWriter == nil {
return http.Header{}
}
return w.ResponseWriter.Header()
}
func (w *openAICompactKeepaliveWriter) Write(data []byte) (int, error) {
w.suspend()
if w.ResponseWriter == nil {
return 0, nil
}
return w.ResponseWriter.Write(data)
}
func (w *openAICompactKeepaliveWriter) WriteString(s string) (int, error) {
w.suspend()
if w.ResponseWriter == nil {
return 0, nil
}
return w.ResponseWriter.WriteString(s)
}
func (w *openAICompactKeepaliveWriter) WriteHeader(code int) {
w.suspend()
if w.ResponseWriter == nil {
return
}
w.ResponseWriter.WriteHeader(code)
}
func (w *openAICompactKeepaliveWriter) WriteHeaderNow() {
w.suspend()
if w.ResponseWriter == nil {
return
}
w.ResponseWriter.WriteHeaderNow()
}
func (w *openAICompactKeepaliveWriter) Flush() {
w.suspend()
if w.ResponseWriter == nil {
return
}
w.ResponseWriter.Flush()
}
func (w *openAICompactKeepaliveWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if w.ResponseWriter == nil {
return nil, nil, errors.New("response writer released")
}
return w.ResponseWriter.Hijack()
}
func (w *openAICompactKeepaliveWriter) CloseNotify() <-chan bool {
if w.ResponseWriter == nil {
ch := make(chan bool)
close(ch)
return ch
}
return w.ResponseWriter.CloseNotify()
}
func (w *openAICompactKeepaliveWriter) Pusher() http.Pusher {
if w.ResponseWriter == nil {
return nil
}
return w.ResponseWriter.Pusher()
}
func (w *openAICompactKeepaliveWriter) Status() int {
if w.k == nil || w.ResponseWriter == nil {
return 0
}
w.k.mu.Lock()
defer w.k.mu.Unlock()
return w.ResponseWriter.Status()
}
func (w *openAICompactKeepaliveWriter) Size() int {
if w.k == nil || w.ResponseWriter == nil {
return 0
}
w.k.mu.Lock()
defer w.k.mu.Unlock()
return w.ResponseWriter.Size()
}
func (w *openAICompactKeepaliveWriter) Written() bool {
if w.k == nil || w.ResponseWriter == nil {
return false
}
w.k.mu.Lock()
defer w.k.mu.Unlock()
return w.ResponseWriter.Written()
}