fix(sse): minimal HEAD response to avoid GCP/Traefik 502 on streaming content-type (#9)

GCP HTTP/2 load balancers (and Traefik in some configs) return 502 to
clients when a HEAD response carries `Connection: keep-alive` plus a
streaming content-type — because they expect HEAD to be a closed-body
response, not an opened SSE stream.

Symptom: external `curl -I https://.../v1/base/realtime` → 502 even
though in-cluster `curl -I http://localhost:8090/v1/base/realtime` →
200 OK with full SSE headers. SPA's `fetch(..., {method:'HEAD'}).then(r => r.ok)`
gets false → live channel `disabled=true` → no live updates ever fire.

Fix: HEAD pre-flight returns ONLY `Content-Type: text/event-stream` +
200, without the streaming-only headers. The full SSE header set still
ships on the actual GET stream.

Applies to both the per-channel HandleSSE() and the multiplexed
HandleMultiplexedSSE() handlers.
This commit is contained in:
Darkhorse7stars
2026-05-21 20:31:32 -07:00
committed by GitHub
parent 08f343c4a1
commit ad72ca6497
+23 -16
View File
@@ -125,9 +125,17 @@ func (r *sseRegistry) fanout(eventType, chain string, body []byte, wildcardEnvel
// name. main.go mounts one per path.
func (h *RealtimeHub) HandleSSE(channel string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// SSE response headers — set BEFORE the HEAD short-circuit so
// the pre-flight sees them and the SPA's `u.ok` check passes
// (200 + text/event-stream → open EventSource).
// HEAD pre-flight: GCP HTTP/2 LB + Traefik can 502 on HEAD
// responses that carry `Connection: keep-alive` together with
// a streaming content-type. Keep the HEAD response minimal —
// the SPA only checks `r.ok` (status) anyway.
if r.Method == http.MethodHead {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
return
}
// SSE response headers for the actual GET stream.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
@@ -136,14 +144,6 @@ func (h *RealtimeHub) HandleSSE(channel string) http.HandlerFunc {
// the whole point of SSE.
w.Header().Set("X-Accel-Buffering", "no")
// HEAD pre-flight from EventSource open. Standard HTTP requires
// HEAD to return the same headers as GET would, with no body.
// We've already set them above; just acknowledge and return.
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
// Should be impossible on Go's net/http; defensive.
@@ -255,16 +255,23 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, payload
// only this one.
func (h *RealtimeHub) HandleMultiplexedSSE() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// HEAD pre-flight: keep the response brief — GCP HTTP/2 load
// balancers (and Traefik in some configs) 502 when a HEAD
// response carries `Connection: keep-alive` + a streaming
// content-type, because they expect HEAD to be a closed-body
// response, not an opened SSE stream. Return only Content-Type
// + 200, no streaming-only headers.
if r.Method == http.MethodHead {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)