Skip to content

Commit ec2fb7e

Browse files
committed
Fix: forward-proxy streams SSE responses without a StreamingResponder
The forward proxy only took its flushing SSE path (handleStreamingResponse) when a StreamingResponder plugin was configured. A plain pipeline (e.g. just token-exchange) fell through to an unflushed io.Copy, so intermittent SSE events from an MCP server never reached the agent until the upstream closed the connection — the agent timed out. Add streamPassthrough: a byte-faithful flushing relay used when no StreamingResponder is present. It copies raw chunks and flushes each write, preserving the event:/id:/retry: lines that generic SSE clients (MCP Streamable HTTP) rely on — re-framing via sseframe would drop them. The WritesBody buffered-fallback guard and the responder re-framing path are unchanged. Add a regression test whose upstream holds the SSE connection open after the first event (the shape the existing repro misses), asserting the event is delivered before the upstream closes and that event:/id:/data: survive verbatim. Fixes #642 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 10d1575 commit ec2fb7e

2 files changed

Lines changed: 211 additions & 4 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package forwardproxy
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
14+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
15+
)
16+
17+
// TestForwardProxy_SSE_StreamsWithoutResponder is the regression test for
18+
// issue #642: a generic (e.g. MCP Streamable HTTP) upstream returns
19+
// text/event-stream, but the outbound pipeline has NO StreamingResponder
20+
// plugin. Before the fix, such a response fell through to an unflushed
21+
// io.Copy, so intermittent SSE events never reached the client until the
22+
// upstream closed the connection — the agent timed out.
23+
//
24+
// Unlike TestForwardProxy_MCP_SSEResponse_RecordsObserve (which uses an
25+
// upstream that writes one frame then RETURNS, so io.Copy sees EOF
26+
// immediately and never exposes the buffering), this upstream flushes one
27+
// event and then holds the connection OPEN. A buffering proxy delivers
28+
// nothing until release; a flushing proxy delivers the event at once.
29+
func TestForwardProxy_SSE_StreamsWithoutResponder(t *testing.T) {
30+
release := make(chan struct{})
31+
closed := false
32+
defer func() {
33+
if !closed {
34+
close(release)
35+
}
36+
}()
37+
38+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
39+
f, ok := w.(http.Flusher)
40+
if !ok {
41+
t.Error("upstream ResponseWriter lacks http.Flusher")
42+
return
43+
}
44+
w.Header().Set("Content-Type", "text/event-stream")
45+
w.WriteHeader(http.StatusOK)
46+
// event: + id: exercise byte-faithful framing — the re-framing path
47+
// (handleStreamingResponse) would drop a non-allowlisted event name
48+
// and the id: line entirely.
49+
io.WriteString(w, "event: message\nid: 42\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"ok\":true}}\n\n")
50+
f.Flush()
51+
<-release // hold the stream open, like a live MCP server between events
52+
}))
53+
defer upstream.Close()
54+
55+
store := session.New(5*time.Minute, 100, 0)
56+
defer store.Close()
57+
58+
// Empty pipeline: HasStreamingResponders()==false and WritesBody()==false,
59+
// so serveOutbound routes to streamPassthrough — the reporter's plain-proxy
60+
// shape (their only outbound plugin, token-exchange, is likewise not a
61+
// StreamingResponder).
62+
pipe, err := pipeline.New(nil)
63+
if err != nil {
64+
t.Fatalf("pipeline.New: %v", err)
65+
}
66+
srv, err := NewServer(pipeline.NewHolder(pipe), store, nil)
67+
if err != nil {
68+
t.Fatalf("NewServer: %v", err)
69+
}
70+
proxy := httptest.NewServer(srv.Handler())
71+
defer proxy.Close()
72+
73+
req, _ := http.NewRequest("POST", upstream.URL+"/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)))
74+
req.Header.Set("Content-Type", "application/json")
75+
proxyClient := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL))}}
76+
resp, err := proxyClient.Do(req)
77+
if err != nil {
78+
t.Fatalf("request failed: %v", err)
79+
}
80+
defer resp.Body.Close()
81+
82+
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
83+
t.Errorf("Content-Type = %q, want text/event-stream", ct)
84+
}
85+
86+
// Read the first full SSE frame in a goroutine, WHILE the upstream is still
87+
// blocked on <-release. A buffering proxy delivers nothing until release, so
88+
// this read blocks and the select hits the timeout — that is the #642
89+
// regression.
90+
type frameResult struct {
91+
data []byte
92+
err error
93+
}
94+
got := make(chan frameResult, 1)
95+
go func() {
96+
br := bufio.NewReader(resp.Body)
97+
var acc []byte
98+
for {
99+
line, err := br.ReadBytes('\n')
100+
acc = append(acc, line...)
101+
if err != nil {
102+
got <- frameResult{acc, err}
103+
return
104+
}
105+
if bytes.HasSuffix(acc, []byte("\n\n")) {
106+
got <- frameResult{acc, nil}
107+
return
108+
}
109+
}
110+
}()
111+
112+
select {
113+
case fr := <-got:
114+
if fr.err != nil && fr.err != io.EOF {
115+
t.Fatalf("reading first SSE frame: %v", fr.err)
116+
}
117+
frame := string(fr.data)
118+
// Byte-faithful framing: event: and id: must survive verbatim.
119+
if !strings.Contains(frame, "event: message") {
120+
t.Errorf("first frame missing 'event: message' (framing not preserved): %q", frame)
121+
}
122+
if !strings.Contains(frame, "id: 42") {
123+
t.Errorf("first frame missing 'id: 42' (framing not preserved): %q", frame)
124+
}
125+
if !strings.Contains(frame, `data: {"jsonrpc":"2.0"`) {
126+
t.Errorf("first frame missing/garbled data line: %q", frame)
127+
}
128+
case <-time.After(2 * time.Second):
129+
t.Fatal("timed out waiting for the first SSE event while upstream held the connection open — proxy buffered the stream (regression of #642)")
130+
}
131+
132+
// Let the upstream handler exit cleanly.
133+
close(release)
134+
closed = true
135+
}

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,23 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge
380380
// declares WritesBody (mutating a body we've already started
381381
// forwarding is incompatible with streaming) — fall back to
382382
// buffered with a warning log instead.
383-
if isEventStream(resp.Header.Get("Content-Type")) &&
384-
s.OutboundPipeline.HasStreamingResponders() &&
385-
resp.Body != nil {
383+
if isEventStream(resp.Header.Get("Content-Type")) && resp.Body != nil {
386384
if s.OutboundPipeline.WritesBody() {
385+
// A body mutator needs the whole body to rewrite it, so it
386+
// can't stream — fall back to the buffered path with a warning.
387387
slog.Warn("forward-proxy: text/event-stream response with WritesBody plugin — falling back to buffered path", "host", r.Host)
388-
} else {
388+
} else if s.OutboundPipeline.HasStreamingResponders() {
389+
// Streaming-aware plugins (inference-parser, a2a-parser) parse
390+
// each SSE frame; handleStreamingResponse re-frames via sseframe.
389391
s.handleStreamingResponse(w, r, resp, pctx)
390392
return
393+
} else {
394+
// No streaming responder: relay the SSE stream byte-for-byte
395+
// with per-write flushing. Re-framing (handleStreamingResponse)
396+
// would drop the event:/id:/retry: lines that generic SSE
397+
// clients (e.g. an MCP Streamable HTTP client) depend on. Fixes #642.
398+
s.streamPassthrough(w, r, resp, pctx)
399+
return
391400
}
392401
}
393402

@@ -651,6 +660,69 @@ func (s *Server) handleStreamingResponse(w http.ResponseWriter, r *http.Request,
651660
}
652661
}
653662

663+
// streamPassthrough forwards a text/event-stream response to the downstream
664+
// client byte-for-byte with per-write flushing. It is the streaming path when
665+
// no StreamingResponder plugin is configured (a plain proxy pipeline). Unlike
666+
// handleStreamingResponse it does NOT parse or re-frame the stream through
667+
// sseframe — it relays the exact upstream bytes so that event:, id:, retry:,
668+
// and comment lines survive, which generic SSE consumers such as an MCP
669+
// Streamable HTTP client require. Without this path such a response fell
670+
// through to an unflushed io.Copy and never reached the client until the
671+
// upstream closed the connection (issue #642).
672+
//
673+
// Response-phase plugins (RunResponse / RunResponseFrame) are intentionally not
674+
// invoked here — consistent with the streaming contract that legacy/body
675+
// plugins don't run on streamed responses — but the response event is still
676+
// recorded for the session API / abctl.
677+
func (s *Server) streamPassthrough(w http.ResponseWriter, r *http.Request, resp *http.Response, pctx *pipeline.Context) {
678+
flusher, ok := w.(http.Flusher)
679+
if !ok {
680+
// No incremental delivery possible on this ResponseWriter — buffer.
681+
// http.Flusher is implemented by net/http's default writer; this is a
682+
// defensive guard for test recorders and exotic wrappers.
683+
slog.Warn("forward-proxy: ResponseWriter does not support flushing — falling back to buffered for streaming response", "host", r.Host)
684+
s.streamFallbackBuffered(w, r, resp, pctx)
685+
return
686+
}
687+
688+
// Record the response event on every exit path (normal EOF, upstream read
689+
// error, downstream write error) so a SessionResponse row still lands.
690+
defer s.recordOutboundResponseEvent(pctx, resp.StatusCode)
691+
692+
// Forward headers + status before the first byte. Drop Content-Length since
693+
// we relay an open-ended chunked stream.
694+
for key, values := range resp.Header {
695+
for _, value := range values {
696+
w.Header().Add(key, value)
697+
}
698+
}
699+
w.Header().Del("Content-Length")
700+
w.WriteHeader(resp.StatusCode)
701+
flusher.Flush()
702+
703+
// Copy raw chunks and flush each so intermittent SSE events reach the client
704+
// immediately. idleReader bounds a wedged upstream; total size stays
705+
// unbounded so long-lived streams aren't cut off.
706+
body := idleReader(resp.Body, streamReadIdleTimeout)
707+
buf := make([]byte, 32*1024)
708+
for {
709+
n, readErr := body.Read(buf)
710+
if n > 0 {
711+
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
712+
slog.Debug("forward-proxy: streaming write error", "host", r.Host, "error", writeErr)
713+
return
714+
}
715+
flusher.Flush()
716+
}
717+
if readErr != nil {
718+
if readErr != io.EOF {
719+
slog.Warn("forward-proxy: streaming response read error", "host", r.Host, "error", readErr)
720+
}
721+
return
722+
}
723+
}
724+
}
725+
654726
// streamFallbackBuffered handles the rare case of a streaming
655727
// Content-Type response on a ResponseWriter that doesn't support
656728
// http.Flusher — buffer the whole SSE body, then re-parse it through

0 commit comments

Comments
 (0)