Submit an issue View all issues Source
MIR-740

http.TimeoutHandler breaks SSE and all streaming responses in httpingress

Done public
phinze phinze Opened Feb 26, 2026 Updated Feb 27, 2026

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:

  1. The upstream server writes event: connected\ndata: {}\n\n and calls Flush() — but this goes into TimeoutHandler's internal buffer, never reaching the client
  2. The connection hangs until RequestTimeout fires, 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 Transport respects context cancellation — slow upstreams get aborted
  • SSE/streaming handlers that select on ctx.Done() clean up on timeout
  • The ResponseWriter is never wrapped, so Flusher, 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