Stream a response (and survive the server's timeouts)¶
transport servers apply timeouts that suit ordinary request/response traffic and
are wrong for a long-lived one. A streamed response hits WriteTimeout and is cut
mid-flight; a large upload hits ReadTimeout and is cut mid-body.
Both are escapable per request. Neither is escapable by accident, which is why this page exists.
The policy¶
Register and NewServer apply these defaults:
| setting | default | what it bounds |
|---|---|---|
ReadTimeout |
5s | reading the whole request, headers and body |
WriteTimeout |
10s | writing the whole response |
IdleTimeout |
120s | waiting between requests on a keep-alive connection |
DefaultMaxRequestBodyBytes |
1 MiB | the size of each request body |
WriteTimeout bounds the entire response, not an individual write. For a
stream that makes it a cap on total stream duration: a reply that takes 13s on a
server with the 10s default delivers about 10s of content and then stops.
Stream a response¶
Set the content type, opt this request out of the write deadline, and check the error from every write:
import (
"net/http"
"time"
transporthttp "gitlab.com/phpboyscout/go/transport/http"
)
func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", transporthttp.EventStreamContentType)
// Opt THIS request out of the write deadline. Without it the stream is cut
// at WriteTimeout.
if err := http.NewResponseController(w).SetWriteDeadline(time.Time{}); err != nil {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
for token := range s.tokens(r.Context()) {
// Always check the error. This is the signal that the stream has been
// cut — see "How you find out" below.
if err := transporthttp.WriteEvent(w, "token", []byte(token)); err != nil {
return
}
}
_ = transporthttp.WriteEvent(w, "done", nil)
}
WriteEvent
writes one Server-Sent Event frame and flushes it. It handles the framing details
that are easy to get wrong: multi-line payloads become consecutive data: lines,
so content containing a newline cannot forge additional fields, and an event name
containing a newline is rejected outright.
It deliberately does not touch the write deadline. Clearing it inside the helper would remove the server's only bound on a client that opens a stream and stops reading — for every caller, without saying so. The opt-out stays yours.
How you find out when a stream is cut¶
Check the error returned by every write. That is the whole answer, and the detail below is why there is no better one.
An expired write deadline does not notify the handler on its own. Nothing at all happens until the handler next attempts a write, at which point that write fails and the request context is cancelled — together, in the same instant.
Two consequences:
r.Context().Done()is not an earlier signal than the write error. It is the same event. Selecting on it is useful for noticing a client that hung up between writes, not for catching the deadline sooner.- A handler idle across the deadline learns nothing until it writes. If your stream can pause — an LLM thinking between tokens, a job reporting progress every 30s — the cut is discovered at the next write, however long that takes.
If a stream can pause for longer than WriteTimeout, clear the deadline. Do not
rely on noticing.
Do not disable the timeout server-wide¶
WithWriteTimeout(0) disables the write deadline for every route on the
server, not just the streaming one. That is a trap, not a shortcut.
WriteTimeout is what bounds a client that opens a response and stops reading.
With it cleared, such a client pins a goroutine, a connection and its buffers for
as long as it likes, and nothing else rescues you:
IdleTimeoutapplies only between requests. A connection mid-response is not idle, so it never engages.ReadTimeouthas already been satisfied — the request was read.
Per-request SetWriteDeadline scopes the exception to the one handler that needs
it. Prefer it always.
Accept a large or slow upload¶
ReadTimeout (5s) covers reading the request body, so a large or slow upload is
cut mid-body. The escape mirrors the write side:
func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
if err := http.NewResponseController(w).SetReadDeadline(time.Time{}); err != nil {
http.Error(w, "cannot extend read deadline", http.StatusInternalServerError)
return
}
// r.Body can now be read for as long as it takes.
}
The same warning applies: WithReadTimeout(0) disables it for every route. It is
also the module's only bound on a slow client trickling headers, because
ReadHeaderTimeout is not set separately — so clearing it server-wide costs more
than it appears to.
Request bodies remain capped at DefaultMaxRequestBodyBytes (1 MiB) regardless;
raise that with WithMaxRequestBodyBytes.
Middleware must stay transparent¶
Both escapes reach the real connection through http.NewResponseController, which
walks the middleware chain via Unwrap(). Any middleware that wraps the
ResponseWriter must implement Unwrap() http.ResponseWriter, and must implement
FlushError() error rather than only Flush().
That second requirement is not a nicety. ResponseController.Flush matches
http.Flusher before it tries Unwrap, so a wrapper offering only Flush ends
the walk and returns nil — discarding the real error and turning a cut stream
back into a silent one. The middleware this module ships satisfies both, and a
test enforces it.
Testing a handler that streams¶
Test against a real server, not httptest.
httptest.NewServer sets none of these timeouts and no body cap, so a handler
tested against it passes whether or not the policy would cut it in production.
That is not a hypothetical: it is how the truncation this page documents reached a
consumer's users with a green test suite.
Build the server through Register with the settings you want to assert against,
and make the test stream for longer than the deadline it is testing.
Where the timeout defaults are defined¶
The numbers on this page are the package defaults, not settings read from anywhere. The full table — with the options that change each one and what a zero value means — is in the HTTP server reference, and the reasons per-route variants do not exist are in What transport does not do.