Skip to content

Commit 2724eda

Browse files
committed
fix(authbridge): Address PR #480 review feedback
Addresses review items from PR #480: - Fix double-dispatch on buffered application/json: Pipeline.RunResponse now skips plugins implementing StreamingResponder so the same body is never delivered through both OnResponse and OnResponseFrame. The framework picks one path per the StreamingResponder contract. - Fix inference clobber on streamFallbackBuffered: re-parse the buffered SSE body frame-by-frame (matches the real streaming dispatch shape) instead of delivering the whole blob as one frame, which would fail json.Unmarshal in the streaming branch and zero out a parsed completion. - Defer-based finalize + record on every exit path of handleStreamingResponse so a client disconnect mid-stream still finalizes aggregating plugins and records the SessionResponse event. - Capture and log the final RunResponseFrame Reject (headers already on the wire, but the policy violation now surfaces). - streamFallbackBuffered: honor RunResponseFrame Reject (headers not yet sent, so a 502/403 is still possible). - Drop hard-coded 30s ResponseHeaderTimeout. Streamable HTTP servers hold headers open until tool completion even for application/json, so a fixed time-to-headers ceiling reproduces #477 on the JSON shape. Request context + per-Read idle timer remain the bounds. - idleReadCloser: sync.Once around close so a stray late timer cannot fire after a successful Read; document the close-to-unblock contract. - SSE reader: accept lone CR as a line terminator and bound non-data line growth at maxSize so pathological comment / unknown-field lines cannot grow memory past the per-frame cap. - mcp-parser: cap per-frame Observe at 50 to bound noisy session timelines on long tools/call streams; emit a single _truncated row past the cap so operators see truncation explicitly. - a2a-parser: cap accumulated artifact text at 64 KiB with a single truncation marker, mirroring the per-stream bounds elsewhere. - Fix multi-line data round-trip: re-split frame on LF at emit time so each original line gets its own data prefix instead of embedding LFs inside one event. - Replace custom indexByteASCII / trimASCIISpace / equalASCIIFold with stdlib strings.IndexByte / TrimSpace / EqualFold in both listeners. - Update StreamingResponder contract comment to reflect the actual proxy ordering (reverseproxy emits frame bytes before OnResponseFrame; forwardproxy emits after) and the new RunResponse skip behavior. Regression tests: assert OnResponse is NOT called on the buffered application/json path for a StreamingResponder plugin in both listeners. All existing tests continue to pass. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
1 parent 194c450 commit 2724eda

10 files changed

Lines changed: 364 additions & 235 deletions

File tree

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 126 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"log/slog"
1313
"net"
1414
"net/http"
15+
"strings"
16+
"sync"
1517
"time"
1618

1719
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
@@ -25,20 +27,6 @@ import (
2527

2628
const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer_limit_bytes
2729

28-
// responseHeaderTimeout bounds the time-to-first-byte (headers) for an
29-
// upstream response. Replaces the old http.Client.Timeout, which
30-
// covered the entire request lifecycle including the body read and so
31-
// killed slow streaming responses (text/event-stream MCP tools/call,
32-
// A2A message/stream, OpenAI streaming chat completions). With the
33-
// timeout moved to the transport's ResponseHeaderTimeout, slow tools
34-
// can stream as long as they like — only a wedged upstream that fails
35-
// to send any headers within this window aborts.
36-
//
37-
// 30s preserves the prior behavior's time-to-headers ceiling. Tools
38-
// that took longer to *return headers* never worked under the old
39-
// configuration either.
40-
const responseHeaderTimeout = 30 * time.Second
41-
4230
// streamReadIdleTimeout caps how long the proxy waits for the next
4331
// byte off a streaming response body. The time.Duration is applied
4432
// per ReadFrame iteration (see streamingResponseBody). A wedged
@@ -96,10 +84,15 @@ func NewServer(outbound *pipeline.Holder, sessions *session.Store, mtls *MTLSOpt
9684
IdleConnTimeout: 90 * time.Second,
9785
TLSHandshakeTimeout: 10 * time.Second,
9886
ExpectContinueTimeout: 1 * time.Second,
99-
// Time-to-headers ceiling for the upstream response. Replaces
100-
// the old http.Client.Timeout (which covered the body read
101-
// and broke streaming). See responseHeaderTimeout.
102-
ResponseHeaderTimeout: responseHeaderTimeout,
87+
// No ResponseHeaderTimeout: Streamable HTTP / MCP servers may
88+
// hold response headers open until a slow tool completes, even
89+
// when the eventual response is application/json (the server
90+
// picks JSON vs SSE per call). A fixed time-to-headers ceiling
91+
// reproduces the original 502 — the pre-headers wait is part
92+
// of the same long tool execution we're trying to permit. The
93+
// inbound request context + the per-Read idle timer on
94+
// streaming bodies are the bounds; an unrecoverably wedged
95+
// upstream is closed when the client cancels the request.
10396
}
10497

10598
if mtls != nil {
@@ -433,11 +426,10 @@ func isEventStream(contentType string) bool {
433426
return false
434427
}
435428
// Strip parameters: "text/event-stream; charset=utf-8" → "text/event-stream".
436-
if idx := indexByteASCIICaseInsensitive(contentType, ';'); idx >= 0 {
429+
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
437430
contentType = contentType[:idx]
438431
}
439-
contentType = trimASCIISpace(contentType)
440-
return equalASCIIFold(contentType, "text/event-stream")
432+
return strings.EqualFold(strings.TrimSpace(contentType), "text/event-stream")
441433
}
442434

443435
// handleStreamingResponse forwards a text/event-stream response to the
@@ -464,6 +456,23 @@ func (s *Server) handleStreamingResponse(w http.ResponseWriter, r *http.Request,
464456
return
465457
}
466458

459+
// Defer the final last=true dispatch + session-event recording so
460+
// every exit path (normal EOF, upstream read error, downstream
461+
// client-write error) finalizes aggregating plugins and records
462+
// the response event. Without this, a client disconnect mid-stream
463+
// leaves inference/a2a stuck in an unfinalized state and emits no
464+
// SessionResponse row to abctl.
465+
defer func() {
466+
finalAction := s.OutboundPipeline.RunResponseFrame(r.Context(), pctx, nil, true)
467+
if finalAction.Type == pipeline.Reject {
468+
// Headers already sent; we can't promote to 502, but
469+
// surface the policy violation so operators see it.
470+
slog.Warn("forward-proxy: streaming response rejected on finalization (headers already sent)",
471+
"host", r.Host, "violation", finalAction.Violation)
472+
}
473+
s.recordOutboundResponseEvent(pctx, resp.StatusCode)
474+
}()
475+
467476
// Forward headers and the streaming status code BEFORE the first
468477
// frame is written. Strip Content-Length since we'll be writing
469478
// chunked, and clear hop-by-hop headers as net/http would.
@@ -506,40 +515,31 @@ func (s *Server) handleStreamingResponse(w http.ResponseWriter, r *http.Request,
506515
break
507516
}
508517

509-
// Write the frame back as a single SSE event. Reconstructing
510-
// "data: ...\n\n" preserves the SSE wire shape regardless of
511-
// whether the upstream used "data: " or "data:" or split across
512-
// multiple data lines. Multi-line data is folded onto one line
513-
// here, which is fine for JSON-RPC payloads (no embedded LF).
514-
if _, err := w.Write([]byte("data: ")); err != nil {
515-
slog.Debug("forward-proxy: streaming write error", "host", r.Host, "error", err)
516-
return
517-
}
518-
if _, err := w.Write(frame); err != nil {
519-
slog.Debug("forward-proxy: streaming write error", "host", r.Host, "error", err)
520-
return
521-
}
522-
if _, err := w.Write([]byte("\n\n")); err != nil {
523-
slog.Debug("forward-proxy: streaming write error", "host", r.Host, "error", err)
524-
return
518+
// Write the frame back as one or more SSE data lines. The
519+
// sseframe reader folds multi-line `data:` events with `\n`
520+
// separators per the spec; re-split here so each original line
521+
// gets its own `data: ` prefix and the downstream parser sees
522+
// the same event boundaries the upstream produced. For the
523+
// single-line JSON-RPC payloads this targets, this loop is
524+
// equivalent to writing `data: <frame>\n\n` once.
525+
if !writeSSEFrame(w, frame) {
526+
slog.Debug("forward-proxy: streaming write error", "host", r.Host)
527+
break
525528
}
526529
flusher.Flush()
527530
bytesWritten += len(frame)
528531
}
529-
530-
// Final last=true call so aggregating plugins (inference-parser,
531-
// a2a-parser) finalize. Always called exactly once even on a zero-
532-
// frame stream (per StreamingResponder contract).
533-
s.OutboundPipeline.RunResponseFrame(r.Context(), pctx, nil, true)
534-
535-
s.recordOutboundResponseEvent(pctx, resp.StatusCode)
536532
}
537533

538534
// streamFallbackBuffered handles the rare case of a streaming
539535
// Content-Type response on a ResponseWriter that doesn't support
540-
// http.Flusher — falls back to the original buffered path. The
541-
// duplicated logic is small enough to keep here rather than refactor
542-
// the main handler around the Flusher fork.
536+
// http.Flusher — buffer the whole SSE body, then re-parse it through
537+
// sseframe so streaming-aware plugins receive one OnResponseFrame call
538+
// per SSE event followed by last=true. Without per-frame dispatch the
539+
// inference parser (and any future fold-and-finalize plugin) would try
540+
// to JSON-decode the whole SSE blob as one chunk, fail, and clobber a
541+
// correctly-parsed completion. Production ResponseWriters implement
542+
// http.Flusher so this path is mostly hit in tests.
543543
func (s *Server) streamFallbackBuffered(w http.ResponseWriter, r *http.Request, resp *http.Response, pctx *pipeline.Context) {
544544
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))
545545
if err != nil {
@@ -561,7 +561,30 @@ func (s *Server) streamFallbackBuffered(w http.ResponseWriter, r *http.Request,
561561
return
562562
}
563563
if s.OutboundPipeline.HasStreamingResponders() {
564-
_ = s.OutboundPipeline.RunResponseFrame(r.Context(), pctx, respBody, true)
564+
// Re-parse the buffered SSE body frame-by-frame so plugins see the
565+
// same per-event shape as the real streaming path. A Reject is
566+
// honored here — headers are not yet on the wire.
567+
reader := sseframe.NewReader(bytes.NewReader(respBody), maxBodySize)
568+
for {
569+
frame, ferr := reader.ReadFrame()
570+
if ferr == io.EOF {
571+
break
572+
}
573+
if ferr != nil {
574+
slog.Warn("forward-proxy: streaming response read error in fallback", "host", r.Host, "error", ferr)
575+
break
576+
}
577+
frameAction := s.OutboundPipeline.RunResponseFrame(r.Context(), pctx, frame, false)
578+
if frameAction.Type == pipeline.Reject {
579+
httpx.WriteRejection(w, frameAction)
580+
return
581+
}
582+
}
583+
finalAction := s.OutboundPipeline.RunResponseFrame(r.Context(), pctx, nil, true)
584+
if finalAction.Type == pipeline.Reject {
585+
httpx.WriteRejection(w, finalAction)
586+
return
587+
}
565588
}
566589
s.recordOutboundResponseEvent(pctx, resp.StatusCode)
567590
for key, values := range resp.Header {
@@ -752,6 +775,41 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
752775
_ = upstream.Close()
753776
}
754777

778+
// writeSSEFrame writes one SSE event built from a sseframe-decoded
779+
// frame back to w. The decoder folds multi-line `data:` events with
780+
// `\n` separators; this helper splits on those `\n`s and emits one
781+
// `data: <line>\n` per original line followed by the blank-line
782+
// terminator, so a downstream SSE parser sees the same event
783+
// boundaries the upstream produced. Returns true when every byte
784+
// was written; false on any write error so the caller can stop
785+
// forwarding without re-checking each Write.
786+
func writeSSEFrame(w io.Writer, frame []byte) bool {
787+
for len(frame) > 0 {
788+
nl := bytes.IndexByte(frame, '\n')
789+
var line []byte
790+
if nl < 0 {
791+
line = frame
792+
frame = nil
793+
} else {
794+
line = frame[:nl]
795+
frame = frame[nl+1:]
796+
}
797+
if _, err := w.Write([]byte("data: ")); err != nil {
798+
return false
799+
}
800+
if _, err := w.Write(line); err != nil {
801+
return false
802+
}
803+
if _, err := w.Write([]byte("\n")); err != nil {
804+
return false
805+
}
806+
}
807+
if _, err := w.Write([]byte("\n")); err != nil {
808+
return false
809+
}
810+
return true
811+
}
812+
755813
// idleReader wraps r so each Read enforces an idle deadline. The
756814
// goroutine pattern (timer reset on every Read entry, cancelled on
757815
// every Read exit) is portable across any io.ReadCloser, unlike
@@ -766,77 +824,43 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
766824
// a long-running tool that emits one byte every minute (within the
767825
// idle window) keeps the stream alive. The streamReadIdleTimeout
768826
// constant captures the wall-clock budget.
827+
//
828+
// Race-with-success note: time.AfterFunc + timer.Stop() does NOT
829+
// wait for an already-fired callback. If the timer fires just as a
830+
// Read returns successfully, the close runs after the success and
831+
// would leave the next Read failing under a healthy upstream. The
832+
// closeOnce field makes the close idempotent and Close() also runs
833+
// it, so a stray late timer is harmless: the underlying body is
834+
// closed at most once, and a successful in-flight Read keeps its
835+
// data either way. The wider hazard — closing the body concurrently
836+
// with an active Read — is the documented unblock mechanism the
837+
// stdlib http transport relies on for forced disconnects.
769838
type idleReadCloser struct {
770-
rc io.ReadCloser
771-
timeout time.Duration
839+
rc io.ReadCloser
840+
timeout time.Duration
841+
closeOnce sync.Once
772842
}
773843

774844
func idleReader(rc io.ReadCloser, timeout time.Duration) io.ReadCloser {
775845
return &idleReadCloser{rc: rc, timeout: timeout}
776846
}
777847

778848
func (i *idleReadCloser) Read(p []byte) (int, error) {
779-
timer := time.AfterFunc(i.timeout, func() {
780-
// Closing the body unblocks Read with an error. Idempotent on
781-
// http.Response.Body (subsequent Closes are no-ops).
782-
_ = i.rc.Close()
783-
})
849+
timer := time.AfterFunc(i.timeout, i.closeIdempotent)
784850
n, err := i.rc.Read(p)
785851
timer.Stop()
786852
return n, err
787853
}
788854

789-
func (i *idleReadCloser) Close() error { return i.rc.Close() }
790-
791-
// indexByteASCIICaseInsensitive returns the index of the first
792-
// occurrence of c in s (ASCII), or -1. Case-insensitive in spirit
793-
// but c is treated as exact — the helper exists for ';' lookup in
794-
// Content-Type headers, where the separator is unambiguous.
795-
func indexByteASCIICaseInsensitive(s string, c byte) int {
796-
for i := 0; i < len(s); i++ {
797-
if s[i] == c {
798-
return i
799-
}
800-
}
801-
return -1
855+
func (i *idleReadCloser) Close() error {
856+
i.closeIdempotent()
857+
return nil
802858
}
803859

804-
// trimASCIISpace strips leading and trailing ASCII whitespace.
805-
// Avoids the strings package's locale-aware Unicode trim because
806-
// HTTP header tokens are ASCII-only.
807-
func trimASCIISpace(s string) string {
808-
start := 0
809-
for start < len(s) && (s[start] == ' ' || s[start] == '\t') {
810-
start++
811-
}
812-
end := len(s)
813-
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
814-
end--
815-
}
816-
return s[start:end]
860+
func (i *idleReadCloser) closeIdempotent() {
861+
i.closeOnce.Do(func() { _ = i.rc.Close() })
817862
}
818863

819-
// equalASCIIFold reports whether s and t are equal under ASCII case
820-
// folding. Used for Content-Type comparison without pulling in a
821-
// Unicode-aware comparator.
822-
func equalASCIIFold(s, t string) bool {
823-
if len(s) != len(t) {
824-
return false
825-
}
826-
for i := 0; i < len(s); i++ {
827-
a, b := s[i], t[i]
828-
if a >= 'A' && a <= 'Z' {
829-
a += 'a' - 'A'
830-
}
831-
if b >= 'A' && b <= 'Z' {
832-
b += 'a' - 'A'
833-
}
834-
if a != b {
835-
return false
836-
}
837-
}
838-
return true
839-
}
840864

841865
// enableKeepalive turns on TCP keepalive with a 30s probe interval on
842866
// the underlying *net.TCPConn, if conn unwraps to one. No-op on other

authbridge/authlib/listener/forwardproxy/streaming_test.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ import (
2020
// observe how the proxy dispatches frames. It records every frame
2121
// it sees along with the last flag, so tests can assert on order
2222
// and finalization. ReadsBody=true so the listener takes the
23-
// NeedsBody path on requests.
23+
// NeedsBody path on requests. OnResponse calls are also counted so
24+
// tests can verify the framework's "pick one path" contract for
25+
// StreamingResponder plugins (OnResponse must NOT be called when
26+
// OnResponseFrame is the dispatch path).
2427
type streamingProbe struct {
25-
mu sync.Mutex
26-
frames [][]byte
27-
lasts []bool
28-
caps pipeline.PluginCapabilities
28+
mu sync.Mutex
29+
frames [][]byte
30+
lasts []bool
31+
onResponseCalls int
32+
caps pipeline.PluginCapabilities
2933
}
3034

3135
func newStreamingProbe(writesBody bool) *streamingProbe {
@@ -43,6 +47,9 @@ func (p *streamingProbe) OnRequest(_ context.Context, _ *pipeline.Context) pipel
4347
return pipeline.Action{Type: pipeline.Continue}
4448
}
4549
func (p *streamingProbe) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action {
50+
p.mu.Lock()
51+
p.onResponseCalls++
52+
p.mu.Unlock()
4653
return pipeline.Action{Type: pipeline.Continue}
4754
}
4855
func (p *streamingProbe) OnResponseFrame(_ context.Context, _ *pipeline.Context, frame []byte, last bool) pipeline.Action {
@@ -65,6 +72,12 @@ func (p *streamingProbe) snapshot() ([][]byte, []bool) {
6572
return frames, lasts
6673
}
6774

75+
func (p *streamingProbe) onResponseCount() int {
76+
p.mu.Lock()
77+
defer p.mu.Unlock()
78+
return p.onResponseCalls
79+
}
80+
6881
// TestForwardProxy_Streaming_FramesFlowThrough asserts that an upstream
6982
// emitting SSE frames over time has those frames flushed downstream as
7083
// they arrive — no full-body buffering, no 30s timeout firing, and
@@ -240,6 +253,13 @@ func TestForwardProxy_BufferedDeliversLastTrueFrame(t *testing.T) {
240253
if !bytes.Equal(frames[0], []byte(`{"ok":true}`)) {
241254
t.Errorf("frame[0] = %q, want JSON body", frames[0])
242255
}
256+
// Regression: a StreamingResponder plugin must NOT have OnResponse
257+
// called on the buffered application/json path — the framework
258+
// picks one path and the buffered last=true frame is it. Otherwise
259+
// migrated plugins would record every JSON response twice.
260+
if got := probe.onResponseCount(); got != 0 {
261+
t.Errorf("OnResponse called %d times; want 0 (StreamingResponder picks the OnResponseFrame path)", got)
262+
}
243263
}
244264

245265
// TestForwardProxy_Streaming_HeadersAndBodyArriveBeforeUpstreamCloses

0 commit comments

Comments
 (0)