Skip to content

Commit 9f5f123

Browse files
committed
fix(forward): cancel stalled upstream streams
1 parent 9421987 commit 9f5f123

3 files changed

Lines changed: 118 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o muxapi-linux-amd64 ./cmd/muxap
128128
| `request_retention_days` | `7` | 请求记录保留天数,每 10 分钟分批删除过期请求与尝试链 |
129129
| `alert_webhook` | (空) | 熔断翻转告警 Webhook URL,留空关闭 |
130130
| `alert_debounce` | `60s` | 告警去抖窗口,同键窗口内最多发一次 |
131-
| `first_response_timeout_ms` | `120000` | 首个响应字节超时,超时后切换渠道;流开始后不再施加应用层超时 |
131+
| `first_response_timeout_ms` | `120000` | 上游首个响应或流中连续无数据的超时;每收到字节都会重新计时,超时后切换渠道或结束卡住的流 |
132132

133133
## API
134134

internal/forward/forward.go

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"io"
1212
"net/http"
1313
"strings"
14+
"sync"
1415
"time"
1516
"unicode/utf8"
1617

@@ -250,15 +251,16 @@ func (f *Forwarder) Forward(w http.ResponseWriter, r *http.Request, body []byte,
250251
}
251252
translate.ConfigureRequestHeaders(req.Header, targetFormat, exchange.Translated())
252253

253-
// 超时覆盖建立连接到首个响应正文;正文开始后由回调停止计时器。
254+
// 计时器覆盖建立连接到响应正文结束;收到任意上游字节会重置,
255+
// 因此正常持续输出的流不会被限制,卡住的流会及时释放渠道占用。
254256
ctx, cancel := context.WithCancelCause(r.Context())
255-
firstByteTimer := time.AfterFunc(f.firstByteTimeout(), func() { cancel(errFirstResponseTimeout) })
257+
watchdog := newResponseWatchdog(f.firstByteTimeout(), cancel)
256258
req = req.WithContext(ctx)
257259
client := &http.Client{Timeout: 0, Transport: candidate.NewTransport()}
258260
start := time.Now()
259261
resp, err := client.Do(req)
260262
if err != nil {
261-
firstByteTimer.Stop()
263+
watchdog.stop()
262264
cause := context.Cause(ctx)
263265
cancel(nil)
264266
release()
@@ -276,11 +278,12 @@ func (f *Forwarder) Forward(w http.ResponseWriter, r *http.Request, body []byte,
276278
lastErr = err
277279
continue
278280
}
281+
resp.Body = watchdogReadCloser{ReadCloser: resp.Body, touch: watchdog.touch}
279282

280283
// 400/404 需要先读取正文,以区分“不支持模型”和普通客户端参数错误。
281284
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusNotFound {
282285
payload, readErr := readLimitedBody(resp.Body, 2<<20)
283-
firstByteTimer.Stop()
286+
watchdog.stop()
284287
cause := context.Cause(ctx)
285288
cancel(nil)
286289
resp.Body.Close()
@@ -335,7 +338,7 @@ func (f *Forwarder) Forward(w http.ResponseWriter, r *http.Request, body []byte,
335338
// 认证、限流及 5xx 属于渠道失败:记录熔断反馈后尝试下一个上游。
336339
if upstream.IsFailureStatus(resp.StatusCode) {
337340
payload, _ := readLimitedBody(resp.Body, 64<<10)
338-
firstByteTimer.Stop()
341+
watchdog.stop()
339342
resp.Body.Close()
340343
cancel(nil)
341344
latency := time.Since(start).Milliseconds()
@@ -349,8 +352,8 @@ func (f *Forwarder) Forward(w http.ResponseWriter, r *http.Request, body []byte,
349352
}
350353

351354
// relayResult.committed 表示响应是否已写出;只有未写出时才能安全换源。
352-
result := relayTranslatedResponse(r.Context(), w, resp, start, func() { firstByteTimer.Stop() }, exchange)
353-
firstByteTimer.Stop()
355+
result := relayTranslatedResponse(r.Context(), w, resp, start, nil, exchange)
356+
watchdog.stop()
354357
cause := context.Cause(ctx)
355358
cancel(nil)
356359
release()
@@ -498,6 +501,83 @@ var errEmptyResponse = errors.New("upstream returned an empty response")
498501
var errErrorPayload = errors.New("upstream returned an error payload with a successful status")
499502
var errFirstResponseTimeout = errors.New("upstream first response timeout")
500503

504+
// responseWatchdog cancels an upstream attempt when no response bytes arrive
505+
// within the configured interval. It is reset by every successful body read,
506+
// so a healthy long-running stream remains allowed to continue.
507+
type responseWatchdog struct {
508+
timeout time.Duration
509+
cancel context.CancelCauseFunc
510+
activity chan struct{}
511+
done chan struct{}
512+
stopped chan struct{}
513+
once sync.Once
514+
}
515+
516+
func newResponseWatchdog(timeout time.Duration, cancel context.CancelCauseFunc) *responseWatchdog {
517+
w := &responseWatchdog{
518+
timeout: timeout, cancel: cancel,
519+
activity: make(chan struct{}, 1), done: make(chan struct{}), stopped: make(chan struct{}),
520+
}
521+
go w.run()
522+
return w
523+
}
524+
525+
func (w *responseWatchdog) run() {
526+
timer := time.NewTimer(w.timeout)
527+
defer func() {
528+
if !timer.Stop() {
529+
select {
530+
case <-timer.C:
531+
default:
532+
}
533+
}
534+
close(w.stopped)
535+
}()
536+
for {
537+
select {
538+
case <-timer.C:
539+
w.cancel(errFirstResponseTimeout)
540+
return
541+
case <-w.activity:
542+
if !timer.Stop() {
543+
select {
544+
case <-timer.C:
545+
default:
546+
}
547+
}
548+
timer.Reset(w.timeout)
549+
case <-w.done:
550+
return
551+
}
552+
}
553+
}
554+
555+
func (w *responseWatchdog) touch() {
556+
select {
557+
case w.activity <- struct{}{}:
558+
default:
559+
}
560+
}
561+
562+
func (w *responseWatchdog) stop() {
563+
w.once.Do(func() { close(w.done) })
564+
<-w.stopped
565+
}
566+
567+
// watchdogReadCloser reports body activity without changing the body API.
568+
type watchdogReadCloser struct {
569+
io.ReadCloser
570+
touch func()
571+
}
572+
573+
func (r watchdogReadCloser) Read(p []byte) (int, error) {
574+
n, err := r.ReadCloser.Read(p)
575+
if n > 0 && r.touch != nil {
576+
r.touch()
577+
}
578+
return n, err
579+
}
580+
501581
type relaySource int
502582

503583
const (

internal/forward/forward_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,36 @@ func TestFirstByteTimeoutFailsOver(t *testing.T) {
648648
}
649649
}
650650

651+
func TestResponseWatchdogReleasesStalledStreamAfterFirstByte(t *testing.T) {
652+
stalled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
653+
w.Header().Set("Content-Type", "text/event-stream")
654+
w.WriteHeader(http.StatusOK)
655+
flusher := w.(http.Flusher)
656+
io.WriteString(w, "data: {\"chunk\":1}\n\n")
657+
flusher.Flush()
658+
<-r.Context().Done()
659+
}))
660+
defer stalled.Close()
661+
662+
upstreams := []*upstream.Upstream{{ID: 1, BaseURL: stalled.URL, APIKey: "k", Priority: 1, Weight: 1}}
663+
hm := health.New(1, time.Hour)
664+
fwd := New(scheduler.New(func(int64) []*upstream.Upstream { return upstreams }, hm), hm, 1)
665+
fwd.SetFirstResponseTimeout(func() time.Duration { return 20 * time.Millisecond })
666+
body := []byte(`{"model":"gpt","stream":true}`)
667+
recorder := httptest.NewRecorder()
668+
started := time.Now()
669+
result := fwd.Forward(recorder, httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)), body, 1, "")
670+
if elapsed := time.Since(started); elapsed > time.Second {
671+
t.Fatalf("stalled stream was not cancelled promptly: %v", elapsed)
672+
}
673+
if result.Outcome != OutcomePartial || result.ErrorKind != "first_response_timeout" {
674+
t.Fatalf("result = %+v, want a timed-out partial stream", result)
675+
}
676+
if hm.EffectiveState(1) != "OPEN" {
677+
t.Fatalf("stalled upstream should be opened by the breaker, state=%s", hm.EffectiveState(1))
678+
}
679+
}
680+
651681
func TestStreamingLatencyUsesTTFT(t *testing.T) {
652682
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
653683
w.Header().Set("Content-Type", "text/event-stream")

0 commit comments

Comments
 (0)