http.TimeoutHandler breaks SSE and all streaming responses in httpingress
Problem
SSE (Server-Sent Events) connections through Miren's HTTP ingress never deliver data to the client. Firefox reports "can't establish a connection" and curl receives 0 bytes before timing out.
Reproduction: curl -v --max-time 10 https://myapp-with-sse.example.com/api/events → 0 bytes received.
Root Cause
httpingress wraps all non-upgrade requests in http.TimeoutHandler (httpingress.go:130):
serv.handler = http.TimeoutHandler(
http.HandlerFunc(serv.handleRequest),
config.RequestTimeout,
timeoutMessage,
)
http.TimeoutHandler buffers the entire response in memory and only sends it when the handler returns (or writes a 503 on timeout). For streaming responses like SSE, the handler intentionally never returns — it blocks in a loop writing events. So:
- The upstream server writes
event: connected\ndata: {}\n\nand callsFlush()— but this goes into TimeoutHandler's internal buffer, never reaching the client - The connection hangs until
RequestTimeoutfires, at which point a 503 is sent instead of the buffered stream
WebSocket upgrades already bypass TimeoutHandler (line 303-311) because it also breaks http.Hijacker, but SSE has the same fundamental incompatibility via buffered writes.
This affects all streaming patterns
Not just SSE — any response that relies on incremental flushing is broken: chunked downloads, long-polling, gRPC-Web streaming, etc. Adding per-protocol exceptions (SSE, then the next thing, then the next) creates a cat-and-mouse dynamic.
Recommended Fix
Replace http.TimeoutHandler with context-based timeouts, which achieve the same "kill slow requests" protection without buffering:
func (h *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), h.requestTimeout)
defer cancel()
h.handleRequest(w, req.WithContext(ctx))
}
Why this works:
- The reverse proxy's
Transportrespects context cancellation — slow upstreams get aborted - SSE/streaming handlers that
selectonctx.Done()clean up on timeout - The
ResponseWriteris never wrapped, soFlusher,Hijacker, etc. all work natively - No exception list needed — all streaming patterns just work
The one tradeoff: http.TimeoutHandler can forcefully write a 503 even if a handler goroutine is completely stuck ignoring the cancelled context. Use http.Server.WriteTimeout as the connection-level backstop for that case.
Key files
servers/httpingress/httpingress.go— TimeoutHandler setup (line 130) and ServeHTTP bypass logic (line 303)pkg/httputil/reverseproxy.go— already has correct SSE flush detection (line 680), just never gets a chance to use it because TimeoutHandler buffers first