A propos Compétences Expérience Services Blog Contact

Tes timeouts Go ne s'appliquent pas là où tu crois Your Go timeouts do not apply where you think

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 Connection est 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-Encoding ni Upgrade dans le handler
  • Le WriteTimeout du 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.

An SSE stream is an HTTP request that never ends. Every default you did not touch is working against it.

TL;DR: your SSE endpoint breaks twice before it reaches your logic. Once because the Connection header is illegal in HTTP/2. Once because your Go server's default timeouts cut the stream at 30 seconds. And if you stay on HTTP/1.1, a permanent stream freezes the rest of your page. In August 2026, Go patched a flaw where a timeout was not applied to HTTP/2 connections. Same lesson: a timeout only protects what it covers.

This article is for Go developers shipping streaming to production. SSE, WebSocket, long-poll: anything that stays open.

The setup

SSE stands for Server-Sent Events. It is a one-way HTTP stream. The server pushes messages, the browser listens.

The format is simple. You open a text/event-stream response, you write lines, you flush. The browser receives them as they come.

I run two SSE endpoints in production. The first is a Go notification service, on Kubernetes, behind a reverse proxy. The second is an internal cockpit that refreshes its UI without a page reload.

Both broke. In different places, with the same symptom.

An SSE stream is a request that never ends

Here is the key to the whole article. To your server, an SSE stream is not a special case. It is a very slow request.

And every guardrail in an HTTP server targets the slow request. Write timeout, context timeout, idle timeout. They exist to kill whatever drags on.

Your legitimate stream looks exactly like what they are meant to kill. That is the whole problem.

The Connection header is illegal in HTTP/2

First incident. The endpoint answers 200, then the browser shows net::ERR_HTTP2_PROTOCOL_ERROR. The client reconnects in a loop.

The cause was one line. My handler set a Connection: keep-alive header. We all copy it from some old SSE tutorial.

Connection is a hop-by-hop header. A hop-by-hop header applies to one network hop only, never end to end. HTTP/2 forbids these headers (RFC 9113 §8.2.2).

The browser speaks HTTP/2 to your ingress. The ingress re-emits your response. The illegal header resets the stream right after the 200.

The silly part is that the header does nothing for you. HTTP/2 is multiplexed and persistent by design. And in HTTP/1.1, keep-alive is already the default.

Keep three headers. Not one more.

// The only headers an SSE stream needs
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // for nginx
w.WriteHeader(http.StatusOK)
flusher.Flush()

// Never here: Connection, Keep-Alive, Transfer-Encoding, Upgrade

Your default timeouts kill the stream at 30 seconds

The illegal header was the visible symptom. The real cause was somewhere else, and it came back a few days later.

My services share an in-house package that builds the HTTP server. It sets sane defaults for an API.

// Defaults from the shared package
ReadTimeout:  15 * time.Second
WriteTimeout: 30 * time.Second
IdleTimeout:  60 * time.Second
// plus a middleware that cancels the context after 30s

Two of those values kill an SSE stream. The WriteTimeout closes the connection while you are writing. The middleware cancels the request context after 30 seconds.

So the stream dies at 30 seconds. The browser shows the same HTTP/2 error, and the client loops again. The symptom blames the protocol. The culprit is your config.

The fix needs both settings. One alone is not enough, and it took me two rounds to learn that.

// You need both, not either
BypassTimeoutPaths:   []string{"/api/v1/notifications/stream"},
WriteTimeoutOverride: map[string]time.Duration{
    "/api/v1/notifications/stream": 0, // 0 = no write timeout
},

A word on the other two timeouts. ReadTimeout is harmless, because the client sends nothing after its request. IdleTimeout is harmless too, as long as you write more often than it fires. In my case: a heartbeat every 30 seconds, an IdleTimeout of 60.

On HTTP/1.1, a permanent stream freezes the rest of your page

Second incident, different project, different layer. I had shipped SSE on an internal cockpit. A few days later, I ripped it out.

The symptom: buttons spinning forever. Requests left and never came back.

The cause was not in my code. A browser caps its connections at about six per origin on HTTP/1.1. An SSE stream holds one of them open forever.

That leaves five slots for the rest of the page. Open a second tab and you are out.

HTTP/2 removes the problem. One tunnel carries every request in parallel, the stream included.

So I put SSE back, with a guard. The endpoint only answers if the request came through the HTTPS front.

// The proxy sets this header, the direct HTTP/1.1 origin does not
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
}

A browser extension still talks to the direct origin, on HTTP/1.1. It gets a 404 on that endpoint and never opens a stream.

Removing SSE was the right call at the time. Putting it back behind an HTTP/2 front was the right call later. Both count.

August 2026: Go patched a timeout that did not apply on HTTP/2

This story just echoed inside the standard library.

On 13 August 2026, the Go team shipped 1.26.6 and 1.25.13. They fix ten security issues. One of them is GO-2026-6089, also known as CVE-2026-56853.

Its official title: "apply ReadHeaderTimeout when doing unencrypted HTTP/2 check". In plain words, ReadHeaderTimeout was not applied while detecting a cleartext HTTP/2 connection.

A client could hold connections open without ever paying the timeout. That is a denial of service through resource exhaustion.

The fix landed in go1.25.13, go1.26.6 and go1.27.0-rc.3. Go 1.27 shipped six days later, on 19 August.

Go check your go.mod files. Most of mine sit on go 1.25, so they were affected.

What I take from it is not the flaw itself. It is the pattern coming back: the timeout existed, it just did not cover the HTTP/2 path.

The checklist before you ship an SSE endpoint

Run this list before a stream goes to production. It would have saved me two incidents.

  • No Connection, Keep-Alive, Transfer-Encoding or Upgrade header in the handler
  • The server WriteTimeout is disabled on that path
  • The timeout middleware is bypassed on that path
  • A heartbeat fires more often than the IdleTimeout
  • The stream is only served behind an HTTP/2 front
  • The proxy does not buffer the response
  • The client reconnects, and you count those reconnections
  • Your Go version is current, standard library timeouts included

What to remember

A timeout only protects what it covers. That holds for your config, and it holds for the standard library.

When a stream breaks, do not start with your business code. Go down to the transport first. Headers, timeouts, and the protocol between the browser and your proxy.

And accept removing a feature that hurts. A disabled SSE beats a frozen page.

Shipping streaming to production and it breaks for no clear reason? Let's talk.