@@ -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
2628const 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.
543543func (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.
769838type idleReadCloser struct {
770- rc io.ReadCloser
771- timeout time.Duration
839+ rc io.ReadCloser
840+ timeout time.Duration
841+ closeOnce sync.Once
772842}
773843
774844func idleReader (rc io.ReadCloser , timeout time.Duration ) io.ReadCloser {
775845 return & idleReadCloser {rc : rc , timeout : timeout }
776846}
777847
778848func (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
0 commit comments