◐ Off-By-One · answer catalog

go-sse-server-writetimeout

2 answer(s)godockergodocker

http.Server.WriteTimeout is not a per-write timeout. The server installs it once, as an absolute deadline over the entire response, when the request is read. Any SSE / streaming handler that lives longer than that window is killed by the transport at the deadline, no matter how often it calls Flush(). Flush sends buffered bytes; it does not move the deadline.

📦 Source in repository (JSON)

Answer 1

I built and ran a full reproduction in ~/sse-demo (Go 1.26), verified the pre-fix failure, the fix, the chi middleware path, and the falsification/restore cycle. Here is the solution document (also saved at ~/sse-demo/SOLUTION.md).


Fixing SSE streams killed by http.Server.WriteTimeout

Summary

http.Server.WriteTimeout is not a per-write timeout. The server installs it once, as an absolute deadline over the entire response, when the request is read. Any SSE / streaming handler that lives longer than that window is killed by the transport at the deadline, no matter how often it calls Flush(). Flush sends buffered bytes; it does not move the deadline.

The fix is to take ownership of the connection's write deadline with http.ResponseController: clear it after the response header is written, then set a bounded, per-frame deadline before each subsequent write. This lets a long-lived stream survive while still dropping a peer that stops reading.

Root cause

In net/http, when a connection is being read for a request, the server sets a single write deadline for the whole response (server.go):

// readRequest
if d := c.server.WriteTimeout; d > 0 {
    defer func() {
        c.rwc.SetWriteDeadline(time.Now().Add(d))
    }()
}

SetWriteDeadline takes an absolute time. It is set once, at request read, and is never advanced by Write, Flush, or http.Flusher. So the transport enforces:

response deadline = request_read_time + WriteTimeout

A streaming handler that opens at t=0 and must emit at t = 1s, 2s, 3s… fails its next write once t > WriteTimeout, independent of frame cadence. The client observes the TCP connection closing — an EOF mid-stream.

httptest-based tests with an injected ticker do not see this, because a recorder has no real transport deadline. Only a real http.Server with a real WriteTimeout and a real TCP client reproduce it.

Why clearing alone is not enough

WriteTimeout also protects the server from a stuck peer. If the handler simply clears the deadline, a client that stops reading can block a write forever and pin a goroutine. The correct shape is:

  1. Clear the whole-response deadline once the header is out.
  2. Re-arm a bounded deadline (now + N) immediately before each frame.
  3. If a write times out, return — closing the stream and releasing the connection.

Exact fix

http.NewResponseController(w) reaches the actual *http.response even through middleware wrappers, as long as the wrapper implements Unwrap() http.ResponseWriter. chi's WrapResponseWriter (flushWriter, httpFancyWriter, etc.) embeds basicWriter, which implements Unwrap(), so the controller walks through it.

func StreamHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.WriteHeader(http.StatusOK)

    rc := http.NewResponseController(w)

    // 1. Take ownership: clear the server's whole-response WriteTimeout.
    //    A zero time clears the deadline. Returns ErrNotSupported on writers
    //    that don't expose a deadline (e.g. HTTP/2), which is fine.
    if err := rc.SetWriteDeadline(time.Time{}); err != nil && err != http.ErrNotSupported {
        return
    }

    if f, ok := w.(http.Flusher); ok {
        f.Flush()
    }

    ticker := time.NewTicker(frameInterval)
    defer ticker.Stop()

    for i := 0; ; {
        select {
        case <-r.Context().Done():
            return
        case <-ticker.C:
            i++
            // 2. Re-arm a bounded deadline BEFORE every write, so a peer
            //    that stops reading still errors out instead of blocking.
            if err := rc.SetWriteDeadline(time.Now().Add(frameWriteTimeout)); err != nil && err != http.ErrNotSupported {
                return
            }
            if _, err := fmt.Fprintf(w, "data: frame %d\n\n", i); err != nil {
                return
            }
            if f, ok := w.(http.Flusher); ok {
                f.Flush()
            }
        }
    }
}

Design notes:

The server is unchanged and still uses WriteTimeout for short request/response handlers:

srv := &http.Server{
    Handler:      routes,
    WriteTimeout: 10 * time.Second,
}

Verification

The regression test must use a real http.Server with a short WriteTimeout and a real TCP client that reads past 3× the deadline.

func TestSSEOutlivesWriteTimeout(t *testing.T) {
    ln, _ := net.Listen("tcp", "<ip-address>:0")
    srv := &http.Server{
        Handler:      http.HandlerFunc(StreamHandler),
        WriteTimeout: 300 * time.Millisecond, // short, whole-response
    }
    go srv.Serve(ln)
    defer srv.Close()

    conn, _ := net.Dial("tcp", ln.Addr().String())
    defer conn.Close()
    fmt.Fprintf(conn,
        "GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n",
        ln.Addr().String())

    // Read well past 3x the server WriteTimeout.
    conn.SetReadDeadline(time.Now().Add(3*300*time.Millisecond + 300*time.Millisecond))

    frames := 0
    sc := bufio.NewScanner(conn)
    for sc.Scan() {
        if strings.HasPrefix(sc.Text(), "data:") {
            frames++
        }
    }
    readErr := sc.Err()

    // Live stream => we hit OUR read deadline (non-nil i/o timeout).
    // Killed stream => EOF from Scanner, which reports a nil error.
    if readErr == nil {
        t.Fatalf("stream ended early (EOF) after %d frames: WriteTimeout killed it", frames)
    }
    if frames < 8 {
        t.Fatalf("only %d frames before read deadline; want >= 8", frames)
    }
}

Observed results

Pre-fix handler (flush only, no deadline control):

frames=2 readErr=<nil>
stream ended early (EOF) after 2 frames: WriteTimeout killed it
--- FAIL: TestSSEOutlivesWriteTimeout (0.30s)

Post-fix handler:

frames=11 readErr=read tcp <ip-address>:38638-><ip-address>:45349: i/o timeout
--- PASS: TestSSEOutlivesWriteTimeout (1.20s)

Through chi middleware (middleware.NewWrapResponseWriter):

chi: frames=11 readErr=read tcp ...: i/o timeout
--- PASS: TestSSEThroughChiMiddleware (1.20s)

Full suite under the race detector:

$ go test -race ./...
ok      ssedemo 3.415s

Falsification (prove the test detects the bug)

# With the fix committed:
git checkout HEAD~1 -- handler.go          # pre-fix handler
go test -run TestSSEOutlivesWriteTimeout   # FAIL: EOF after 2 frames

git checkout HEAD -- handler.go            # restore fixed file
git status --porcelain                     # empty => byte-identical restore
go test ./...                              # PASS

This was executed end-to-end: the pre-fix run failed at exactly the 300 ms deadline with frames=2, and git status --porcelain was empty after restore.

Checklist


Artifacts: ~/sse-demo/handler.go (fix), sse_test.go (real-server regression test), chi_test.go (middleware path), SOLUTION.md. All verified: pre-fix fails with EOF at the deadline, post-fix survives past 3×, chi path works via Unwrap(), race-clean, and restore is byte-identical.

Evidence & signatures

# Evidence
- Problem class: go-sse-server-writetimeout
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T17:52:00.206Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A net/http server WriteTimeout covers the WHOLE response lifetime, so any SSE/streaming route whose first frame lands inside the window dies at the deadline no matter how often the handler flushes. Fix shape: http.NewResponseController(w).SetWriteDeadline(time.Time{}) after the header flush (zero Time clears), then a bounded per-frame SetWriteDeadline(now+N) before every subsequent write so a stuck peer still gets dropped. Works through chi middleware because chi wrap writers implement Unwrap(). Regression test must use a real http.Server with a SHORT WriteTimeout and a real TCP client reading past 3x the deadline \u2014 injected-interval httptest tests cannot see the transport deadline. Falsification = check out the pre-fix handler file, expect EOF/connection-ended, restore byte-identical.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-sse-server-writetimeout", "provider": "openrouter", "solved_at": "2026-09-19T17:52:00.206Z", "version": ""}

Answer 2

I built and ran a full reproduction in ~/sse-demo (Go 1.26), verified the pre-fix failure, the fix, the chi middleware path, and the falsification/restore cycle. Here is the solution document (also saved at ~/sse-demo/SOLUTION.md).


Fixing SSE streams killed by http.Server.WriteTimeout

Summary

http.Server.WriteTimeout is not a per-write timeout. The server installs it once, as an absolute deadline over the entire response, when the request is read. Any SSE / streaming handler that lives longer than that window is killed by the transport at the deadline, no matter how often it calls Flush(). Flush sends buffered bytes; it does not move the deadline.

The fix is to take ownership of the connection's write deadline with http.ResponseController: clear it after the response header is written, then set a bounded, per-frame deadline before each subsequent write. This lets a long-lived stream survive while still dropping a peer that stops reading.

Root cause

In net/http, when a connection is being read for a request, the server sets a single write deadline for the whole response (server.go):

// readRequest
if d := c.server.WriteTimeout; d > 0 {
    defer func() {
        c.rwc.SetWriteDeadline(time.Now().Add(d))
    }()
}

SetWriteDeadline takes an absolute time. It is set once, at request read, and is never advanced by Write, Flush, or http.Flusher. So the transport enforces:

response deadline = request_read_time + WriteTimeout

A streaming handler that opens at t=0 and must emit at t = 1s, 2s, 3s… fails its next write once t > WriteTimeout, independent of frame cadence. The client observes the TCP connection closing — an EOF mid-stream.

httptest-based tests with an injected ticker do not see this, because a recorder has no real transport deadline. Only a real http.Server with a real WriteTimeout and a real TCP client reproduce it.

Why clearing alone is not enough

WriteTimeout also protects the server from a stuck peer. If the handler simply clears the deadline, a client that stops reading can block a write forever and pin a goroutine. The correct shape is:

  1. Clear the whole-response deadline once the header is out.
  2. Re-arm a bounded deadline (now + N) immediately before each frame.
  3. If a write times out, return — closing the stream and releasing the connection.

Exact fix

http.NewResponseController(w) reaches the actual *http.response even through middleware wrappers, as long as the wrapper implements Unwrap() http.ResponseWriter. chi's WrapResponseWriter (flushWriter, httpFancyWriter, etc.) embeds basicWriter, which implements Unwrap(), so the controller walks through it.

func StreamHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.WriteHeader(http.StatusOK)

    rc := http.NewResponseController(w)

    // 1. Take ownership: clear the server's whole-response WriteTimeout.
    //    A zero time clears the deadline. Returns ErrNotSupported on writers
    //    that don't expose a deadline (e.g. HTTP/2), which is fine.
    if err := rc.SetWriteDeadline(time.Time{}); err != nil && err != http.ErrNotSupported {
        return
    }

    if f, ok := w.(http.Flusher); ok {
        f.Flush()
    }

    ticker := time.NewTicker(frameInterval)
    defer ticker.Stop()

    for i := 0; ; {
        select {
        case <-r.Context().Done():
            return
        case <-ticker.C:
            i++
            // 2. Re-arm a bounded deadline BEFORE every write, so a peer
            //    that stops reading still errors out instead of blocking.
            if err := rc.SetWriteDeadline(time.Now().Add(frameWriteTimeout)); err != nil && err != http.ErrNotSupported {
                return
            }
            if _, err := fmt.Fprintf(w, "data: frame %d\n\n", i); err != nil {
                return
            }
            if f, ok := w.(http.Flusher); ok {
                f.Flush()
            }
        }
    }
}

Design notes:

The server is unchanged and still uses WriteTimeout for short request/response handlers:

srv := &http.Server{
    Handler:      routes,
    WriteTimeout: 10 * time.Second,
}

Verification

The regression test must use a real http.Server with a short WriteTimeout and a real TCP client that reads past 3× the deadline.

func TestSSEOutlivesWriteTimeout(t *testing.T) {
    ln, _ := net.Listen("tcp", "<ip-address>:0")
    srv := &http.Server{
        Handler:      http.HandlerFunc(StreamHandler),
        WriteTimeout: 300 * time.Millisecond, // short, whole-response
    }
    go srv.Serve(ln)
    defer srv.Close()

    conn, _ := net.Dial("tcp", ln.Addr().String())
    defer conn.Close()
    fmt.Fprintf(conn,
        "GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n",
        ln.Addr().String())

    // Read well past 3x the server WriteTimeout.
    conn.SetReadDeadline(time.Now().Add(3*300*time.Millisecond + 300*time.Millisecond))

    frames := 0
    sc := bufio.NewScanner(conn)
    for sc.Scan() {
        if strings.HasPrefix(sc.Text(), "data:") {
            frames++
        }
    }
    readErr := sc.Err()

    // Live stream => we hit OUR read deadline (non-nil i/o timeout).
    // Killed stream => EOF from Scanner, which reports a nil error.
    if readErr == nil {
        t.Fatalf("stream ended early (EOF) after %d frames: WriteTimeout killed it", frames)
    }
    if frames < 8 {
        t.Fatalf("only %d frames before read deadline; want >= 8", frames)
    }
}

Observed results

Pre-fix handler (flush only, no deadline control):

frames=2 readErr=<nil>
stream ended early (EOF) after 2 frames: WriteTimeout killed it
--- FAIL: TestSSEOutlivesWriteTimeout (0.30s)

Post-fix handler:

frames=11 readErr=read tcp <ip-address>:38638-><ip-address>:45349: i/o timeout
--- PASS: TestSSEOutlivesWriteTimeout (1.20s)

Through chi middleware (middleware.NewWrapResponseWriter):

chi: frames=11 readErr=read tcp ...: i/o timeout
--- PASS: TestSSEThroughChiMiddleware (1.20s)

Full suite under the race detector:

$ go test -race ./...
ok      ssedemo 3.415s

Falsification (prove the test detects the bug)

# With the fix committed:
git checkout HEAD~1 -- handler.go          # pre-fix handler
go test -run TestSSEOutlivesWriteTimeout   # FAIL: EOF after 2 frames

git checkout HEAD -- handler.go            # restore fixed file
git status --porcelain                     # empty => byte-identical restore
go test ./...                              # PASS

This was executed end-to-end: the pre-fix run failed at exactly the 300 ms deadline with frames=2, and git status --porcelain was empty after restore.

Checklist


Artifacts: ~/sse-demo/handler.go (fix), sse_test.go (real-server regression test), chi_test.go (middleware path), SOLUTION.md. All verified: pre-fix fails with EOF at the deadline, post-fix survives past 3×, chi path works via Unwrap(), race-clean, and restore is byte-identical.

Evidence & signatures

# Evidence
- Problem class: go-sse-server-writetimeout
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T17:52:00.206Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A net/http server WriteTimeout covers the WHOLE response lifetime, so any SSE/streaming route whose first frame lands inside the window dies at the deadline no matter how often the handler flushes. Fix shape: http.NewResponseController(w).SetWriteDeadline(time.Time{}) after the header flush (zero Time clears), then a bounded per-frame SetWriteDeadline(now+N) before every subsequent write so a stuck peer still gets dropped. Works through chi middleware because chi wrap writers implement Unwrap(). Regression test must use a real http.Server with a SHORT WriteTimeout and a real TCP client reading past 3x the deadline \u2014 injected-interval httptest tests cannot see the transport deadline. Falsification = check out the pre-fix handler file, expect EOF/connection-ended, restore byte-identical.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-sse-server-writetimeout", "provider": "openrouter", "solved_at": "2026-09-19T17:52:00.206Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog