Skip to content

Commit 76d1ac5

Browse files
juliendoclotclaude
andauthored
fix: prevent panic on SSE reverse proxy when backend closes connection (#128)
* fix: prevent panic on SSE reverse proxy when backend closes connection The default httputil.ReverseProxy panics with "net/http: abort Handler" when proxying SSE (Server-Sent Events) streams and the backend closes the connection. This happens frequently with MCP Streamable HTTP backends (e.g. n8n) when sessions expire or clients disconnect. Add ErrorHandler to log the error instead of panicking, and set FlushInterval to -1 for immediate SSE flushing. Fixes the panic at reverseproxy.go:543 in handleProxy (proxy.go:90). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: recover from ErrAbortHandler panic in SSE proxy handler The previous ErrorHandler fix in transparent.go only covers transport errors. The actual panic at reverseproxy.go:543 is http.ErrAbortHandler triggered during response flush when an SSE stream ends. This panic bypasses ErrorHandler entirely. Add a deferred recover() in proxy.go's handleProxy to catch ErrAbortHandler panics silently (they are expected for SSE) while re-panicking on unexpected errors. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 9803d0f commit 76d1ac5

2 files changed

Lines changed: 48 additions & 0 deletions

File tree

pkg/backend/transparent.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,27 @@ func (p *TransparentBackend) Run(ctx context.Context) (http.Handler, error) {
125125
base: http.DefaultTransport,
126126
targetHost: p.url.Host,
127127
},
128+
// FlushInterval -1 enables immediate flushing for SSE streams.
129+
// Without this the proxy buffers the response, breaking
130+
// Server-Sent Events used by MCP Streamable HTTP.
131+
FlushInterval: -1,
132+
// ErrorHandler prevents the default ReverseProxy behavior of
133+
// panicking when the backend closes the connection (e.g. when
134+
// an SSE stream ends or the client disconnects). Instead we
135+
// log the error and return a 502 to the client.
136+
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
137+
p.logger.Warn("reverse proxy error",
138+
zap.String("method", r.Method),
139+
zap.String("path", r.URL.Path),
140+
zap.Error(err),
141+
)
142+
// Only write an error response if headers haven't been sent yet
143+
// (i.e. the stream hasn't started). For in-flight SSE streams
144+
// the ResponseWriter is already flushed, so just return.
145+
if !isHeadersSent(w) {
146+
http.Error(w, "Bad Gateway", http.StatusBadGateway)
147+
}
148+
},
128149
Rewrite: func(pr *httputil.ProxyRequest) {
129150
pr.SetURL(p.url)
130151
if p.isTrusted(pr.In.RemoteAddr) {
@@ -147,6 +168,17 @@ func (p *TransparentBackend) Run(ctx context.Context) (http.Handler, error) {
147168
return &rp, nil
148169
}
149170

171+
// isHeadersSent checks whether the ResponseWriter has already started sending
172+
// the response (status + headers). Once flushed we can no longer write an
173+
// error status, so callers should just close the connection instead.
174+
func isHeadersSent(w http.ResponseWriter) bool {
175+
// A non-zero status in the header map means WriteHeader was called.
176+
// The standard http.response tracks this internally; the only reliable
177+
// external signal is whether Content-Type (or any header set by the
178+
// backend) is already present in the response.
179+
return w.Header().Get("Content-Type") != ""
180+
}
181+
150182
func (p *TransparentBackend) isTrusted(hostport string) bool {
151183
if host, _, err := net.SplitHostPort(hostport); err == nil {
152184
hostport = host

pkg/proxy/proxy.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ func (p *ProxyRouter) handleProxy(c *gin.Context) {
8787
}
8888
}
8989

90+
// Recover from http.ErrAbortHandler panics that occur when SSE
91+
// streams end (backend closes connection, client disconnects).
92+
// The default ReverseProxy panics with ErrAbortHandler on flush
93+
// errors — this is expected for SSE and should not crash the proxy.
94+
defer func() {
95+
if r := recover(); r != nil {
96+
// http.ErrAbortHandler is the sentinel panic used by
97+
// net/http to silently abort a handler. Let it go.
98+
if r == http.ErrAbortHandler {
99+
return
100+
}
101+
// Re-panic for anything unexpected
102+
panic(r)
103+
}
104+
}()
105+
90106
p.proxy.ServeHTTP(c.Writer, c.Request)
91107
}
92108

0 commit comments

Comments
 (0)