Un flux SSE, c'est une requête HTTP qui ne se termine jamais. Tous tes réglages par défaut sont contre elle.
TL;DR : ton endpoint SSE casse deux fois avant d'atteindre ta logique. Une fois parce que le header
Connectionest interdit en HTTP/2. Une fois parce que les timeouts par défaut de ton serveur Go coupent le flux à 30 secondes. Et si tu restes en HTTP/1.1, un flux permanent gèle le reste de ta page. En août 2026, Go a corrigé une faille où un timeout ne s'appliquait pas aux connexions HTTP/2. Même leçon : un timeout ne protège que ce qu'il couvre.
Cet article est pour les devs Go qui mettent du streaming en production. SSE, WebSocket, long-poll : tout ce qui dure.
Le contexte
SSE veut dire Server-Sent Events. C'est un flux HTTP à sens unique. Le serveur pousse des messages, le navigateur écoute.
Le format est simple. Tu ouvres une réponse text/event-stream, tu écris des lignes, tu vides le tampon. Le navigateur reçoit au fil de l'eau.
J'ai deux endpoints SSE en production. Le premier est un service de notifications en Go, dans Kubernetes, derrière un reverse proxy. Le second est un cockpit interne qui rafraîchit son interface sans recharger la page.
Les deux ont cassé. À des endroits différents, avec le même symptôme.
Un flux SSE, c'est une requête qui ne finit jamais
Voilà la clé de tout l'article. Pour ton serveur, un flux SSE n'est pas un cas particulier. C'est une requête très lente.
Or les garde-fous d'un serveur HTTP visent précisément la requête lente. Timeout d'écriture, timeout de contexte, timeout d'inactivité. Ils existent pour tuer ce qui traîne.
Ton flux légitime ressemble exactement à ce qu'ils doivent tuer. Tout le problème est là.
Le header Connection est interdit en HTTP/2
Premier incident. L'endpoint répond 200, puis le navigateur affiche net::ERR_HTTP2_PROTOCOL_ERROR. Le client se reconnecte en boucle.
La cause tenait en une ligne. Mon handler posait un header Connection: keep-alive. On le copie tous depuis un vieux tutoriel SSE.
Connection est un header hop-by-hop. Un header hop-by-hop ne vaut que pour un seul saut réseau, jamais de bout en bout. HTTP/2 interdit ces headers (RFC 9113 §8.2.2).
Le navigateur parle en HTTP/2 avec ton ingress. L'ingress réémet ta réponse. Le header illégal fait tomber le stream juste après le 200.
Le plus bête, c'est que ce header ne sert à rien. HTTP/2 est multiplexé et persistant par nature. Et en HTTP/1.1, keep-alive est déjà le comportement par défaut.
Garde trois headers, pas un de plus.
// Les seuls headers utiles sur un flux SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // pour nginx
w.WriteHeader(http.StatusOK)
flusher.Flush()
// Interdits ici : Connection, Keep-Alive, Transfer-Encoding, Upgrade
Tes timeouts par défaut tuent le flux à 30 secondes
Le header illégal était le symptôme visible. La cause de fond était ailleurs, et elle est revenue quelques jours plus tard.
Mes services partagent un package maison qui construit le serveur HTTP. Il pose des valeurs par défaut raisonnables pour une API.
// Les défauts du package partagé
ReadTimeout: 15 * time.Second
WriteTimeout: 30 * time.Second
IdleTimeout: 60 * time.Second
// plus un middleware qui annule le contexte après 30 s
Deux de ces valeurs tuent un flux SSE. Le WriteTimeout ferme la connexion pendant que tu écris. Le middleware annule le contexte de la requête au bout de 30 secondes.
Le flux meurt donc à 30 secondes. Le navigateur affiche la même erreur HTTP/2, et le client reboucle. Le symptôme accuse le protocole. Le coupable est ta configuration.
Le correctif demande les deux réglages. Un seul ne suffit pas, je l'ai appris en deux allers-retours.
// Il faut les deux, pas l'un ou l'autre
BypassTimeoutPaths: []string{"/api/v1/notifications/stream"},
WriteTimeoutOverride: map[string]time.Duration{
"/api/v1/notifications/stream": 0, // 0 = pas de timeout d'écriture
},
Un mot sur les deux autres timeouts. ReadTimeout ne gêne pas, parce que le client n'envoie plus rien après sa requête. IdleTimeout ne gêne pas non plus, tant que tu écris plus souvent que lui. Chez moi : un battement de cœur toutes les 30 secondes, un IdleTimeout à 60.
En HTTP/1.1, un flux permanent gèle le reste de ta page
Deuxième incident, autre projet, autre couche. J'avais shippé du SSE sur un cockpit interne. Quelques jours plus tard, je l'ai arraché.
Le symptôme : les boutons tournaient en rond. Les requêtes partaient et n'arrivaient jamais.
La cause n'était pas dans mon code. Un navigateur limite ses connexions à six environ par origine, en HTTP/1.1. Un flux SSE en garde une, ouverte pour toujours.
Il reste cinq places pour tout le reste de la page. Ouvre un deuxième onglet et tu es à court.
HTTP/2 supprime le problème. Un seul tunnel porte toutes les requêtes en parallèle, flux compris.
J'ai donc remis le SSE en service, mais avec une garde. L'endpoint ne répond que si la requête est passée par le front HTTPS.
// Le proxy pose ce header, l'origine directe en HTTP/1.1 non
func servedOverHTTP2(r *http.Request) bool {
return r.Header.Get("X-Forwarded-Proto") == "https"
}
if !servedOverHTTP2(r) {
http.Error(w, "live updates unavailable", http.StatusNotFound)
return
}
Une extension de navigateur parle encore à l'origine directe, en HTTP/1.1. Elle reçoit un 404 sur cet endpoint et n'ouvre jamais de flux.
Retirer le SSE était la bonne décision sur le moment. Le remettre derrière un front HTTP/2 était la bonne décision ensuite. Les deux comptent.
Août 2026 : Go a corrigé un timeout qui ne s'appliquait pas en HTTP/2
Cette histoire vient d'avoir un écho dans la bibliothèque standard.
Le 13 août 2026, l'équipe Go publie les versions 1.26.6 et 1.25.13. Elles corrigent dix failles. L'une s'appelle GO-2026-6089, alias CVE-2026-56853.
Son titre officiel : « apply ReadHeaderTimeout when doing unencrypted HTTP/2 check ». Autrement dit, ReadHeaderTimeout n'était pas appliqué pendant la détection d'une connexion HTTP/2 en clair.
Un client pouvait donc tenir des connexions ouvertes sans jamais payer le timeout. C'est un déni de service par épuisement de ressources.
Le correctif est dans go1.25.13, go1.26.6 et go1.27.0-rc.3. Go 1.27 est sorti six jours plus tard, le 19 août.
Va regarder tes fichiers go.mod. Les miens sont surtout en go 1.25, donc concernés.
Ce que je retiens n'est pas la faille elle-même. C'est le motif qui revient : le timeout existait, mais il ne couvrait pas le chemin HTTP/2.
La checklist avant de shipper un endpoint SSE
Avant de mettre un flux en production, passe la liste. Elle m'aurait fait gagner deux incidents.
- Aucun header
Connection,Keep-Alive,Transfer-EncodingniUpgradedans le handler - Le
WriteTimeoutdu serveur est désactivé sur ce chemin - Le middleware de timeout est court-circuité sur ce chemin
- Un battement de cœur part plus souvent que l'
IdleTimeout - Le flux n'est servi que derrière un front HTTP/2
- Le proxy ne met pas la réponse en tampon
- Le client sait se reconnecter, et tu sais compter ses reconnexions
- Ta version de Go est à jour, timeouts de la stdlib compris
Ce qu'il faut retenir
Un timeout ne protège que ce qu'il couvre. C'est vrai pour ta config, et c'est vrai pour la bibliothèque standard.
Quand un flux casse, ne commence pas par ton code métier. Descends d'abord dans le transport. Les headers, les timeouts, le protocole entre le navigateur et ton proxy.
Et accepte de retirer une fonctionnalité qui nuit. Un SSE désactivé vaut mieux qu'une page gelée.
Tu mets du streaming en production et ça casse sans raison claire ? Parlons-en.