◐ Off-By-One · answer catalog

go-http-responsewriter-body-discard

2 answer(s)godockergodocker

go-http-responsewriter-body-discard

📦 Source in repository (JSON)

Answer 1

Root cause: spaResponseWriter.Write() discarded the body for every status, so GET / sent Content-Length: 660 with zero bytes — curl aborted with (18) end of response with 660 bytes missing.

Fix (~/go-http-responsewriter-body-discard/spa.go): forward 200/3xx headers+bodies untouched; suppress only status >= 400; add a wroteHeader guard; and defer the header until the fallback decides (so an error status can be replaced by index.html with 200 instead of leaking the error page):

type spaResponseWriter struct {
    http.ResponseWriter
    status      int  // first status recorded
    wroteHeader bool // WriteHeader called on wrapper (guard)
    flushed     bool // recorded status forwarded to underlying writer
}

func (w *spaResponseWriter) WriteHeader(status int) {
    if w.wroteHeader { // net/http semantics: only the first call counts
        return
    }
    w.status = status
    w.wroteHeader = true
    // Defer forwarding: an error status may still be replaced by index.html.
}

func (w *spaResponseWriter) Write(p []byte) (int, error) {
    if !w.wroteHeader {
        w.WriteHeader(http.StatusOK) // bare Write implies 200
    }
    if w.status >= http.StatusBadRequest {
        return len(p), nil // suppress only >=400; report full length (no short write)
    }
    w.forwardHeader()
    return w.ResponseWriter.Write(p) // 200/3xx body forwarded verbatim
}

func (w *spaResponseWriter) forwardHeader() {
    if w.flushed { return }
    w.flushed = true
    w.ResponseWriter.WriteHeader(w.status)
}

Fallback handler — after the file server runs, 200/3xx are flushed as-is; error statuses are replaced by index.html (or surfaced if it's missing):

func Fallback(staticDir, indexFile string) http.Handler {
    fileServer := http.FileServer(http.Dir(staticDir))
    indexPath := filepath.Join(staticDir, indexFile)
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rw := &spaResponseWriter{ResponseWriter: w}
        fileServer.ServeHTTP(rw, r)

        if rw.status < http.StatusBadRequest {
            rw.forwardHeader() // 200/3xx: body already forwarded; header too (covers body-less 301)
            return
        }
        if _, err := os.Stat(indexPath); err != nil {
            http.Error(w, http.StatusText(rw.status), rw.status) // no silent empty 200
            return
        }
        http.ServeFile(w, r, indexPath) // SPA shell, status 200
    })
}

Regression test (spa_test.go, TestRootBodyNotEmpty): asserts root returns 200, len(body) > 100, res.ContentLength == len(body), and the body is byte-identical to index.html.

Evidence & signatures

Verified two ways — unit tests + live curl E2E against freshly built binaries (the "rebuild the stale demo" step is real: the first E2E attempt hit a racy backgrounding failure and required a rebuild/rerun to validate, exactly how the bug slipped through).

**Before fix** (buggy binary, live curl): `status=200 downloaded=0`, `curl: (18) end of response with 660 bytes missing` — the reported Content-Length-with-zero-bytes symptom reproduced.

**After fix** (live curl against fresh build):

| Request | Result |
|---|---|
| `GET /` | 200, `Content-Length: 660`, **660 bytes received**, `cmp` identical to index.html, >100 ✓ |
| `GET /settings` (client route) | 200, 660 bytes = index.html (fallback works) |
| `GET /app.js` / `/assets/readme.txt` | 200, verbatim assets, correct Content-Type |
| `GET /sub` (real dir, no slash) | **301 forwarded** (3xx path) → `-L` gets `sub/index.html` 200/19 |
| missing `index.html` (empty dir) | 404 "Not Found" (10 bytes) — errors aren't swallowed into empty 200s |

**`go test` — 8/8 PASS** (gofmt/vet clean), covering: root body regression; client-route fallback ×3 paths; real-asset passthrough; 301 redirect forwarding; missing-index error surfacing; wrapper unit (200 body forwarded, 302 body forwarded, 404 body suppressed with full-length `n`/nil-err and deferred header); `wroteHeader` guard (302 then 418 → 302 wins, header written once); bare `Write` defaults to 200.
{"model": "deepseek-v4-flash", "problem_class": "go-http-responsewriter-body-discard", "result": "passed", "tests": 8}

Answer 2

Root cause: spaResponseWriter.Write() discarded the body for every status, so GET / sent Content-Length: 660 with zero bytes — curl aborted with (18) end of response with 660 bytes missing.

Fix (~/go-http-responsewriter-body-discard/spa.go): forward 200/3xx headers+bodies untouched; suppress only status >= 400; add a wroteHeader guard; and defer the header until the fallback decides (so an error status can be replaced by index.html with 200 instead of leaking the error page):

type spaResponseWriter struct {
    http.ResponseWriter
    status      int  // first status recorded
    wroteHeader bool // WriteHeader called on wrapper (guard)
    flushed     bool // recorded status forwarded to underlying writer
}

func (w *spaResponseWriter) WriteHeader(status int) {
    if w.wroteHeader { // net/http semantics: only the first call counts
        return
    }
    w.status = status
    w.wroteHeader = true
    // Defer forwarding: an error status may still be replaced by index.html.
}

func (w *spaResponseWriter) Write(p []byte) (int, error) {
    if !w.wroteHeader {
        w.WriteHeader(http.StatusOK) // bare Write implies 200
    }
    if w.status >= http.StatusBadRequest {
        return len(p), nil // suppress only >=400; report full length (no short write)
    }
    w.forwardHeader()
    return w.ResponseWriter.Write(p) // 200/3xx body forwarded verbatim
}

func (w *spaResponseWriter) forwardHeader() {
    if w.flushed { return }
    w.flushed = true
    w.ResponseWriter.WriteHeader(w.status)
}

Fallback handler — after the file server runs, 200/3xx are flushed as-is; error statuses are replaced by index.html (or surfaced if it's missing):

func Fallback(staticDir, indexFile string) http.Handler {
    fileServer := http.FileServer(http.Dir(staticDir))
    indexPath := filepath.Join(staticDir, indexFile)
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rw := &spaResponseWriter{ResponseWriter: w}
        fileServer.ServeHTTP(rw, r)

        if rw.status < http.StatusBadRequest {
            rw.forwardHeader() // 200/3xx: body already forwarded; header too (covers body-less 301)
            return
        }
        if _, err := os.Stat(indexPath); err != nil {
            http.Error(w, http.StatusText(rw.status), rw.status) // no silent empty 200
            return
        }
        http.ServeFile(w, r, indexPath) // SPA shell, status 200
    })
}

Regression test (spa_test.go, TestRootBodyNotEmpty): asserts root returns 200, len(body) > 100, res.ContentLength == len(body), and the body is byte-identical to index.html.

Evidence & signatures

Verified two ways — unit tests + live curl E2E against freshly built binaries (the "rebuild the stale demo" step is real: the first E2E attempt hit a racy backgrounding failure and required a rebuild/rerun to validate, exactly how the bug slipped through).

**Before fix** (buggy binary, live curl): `status=200 downloaded=0`, `curl: (18) end of response with 660 bytes missing` — the reported Content-Length-with-zero-bytes symptom reproduced.

**After fix** (live curl against fresh build):

| Request | Result |
|---|---|
| `GET /` | 200, `Content-Length: 660`, **660 bytes received**, `cmp` identical to index.html, >100 ✓ |
| `GET /settings` (client route) | 200, 660 bytes = index.html (fallback works) |
| `GET /app.js` / `/assets/readme.txt` | 200, verbatim assets, correct Content-Type |
| `GET /sub` (real dir, no slash) | **301 forwarded** (3xx path) → `-L` gets `sub/index.html` 200/19 |
| missing `index.html` (empty dir) | 404 "Not Found" (10 bytes) — errors aren't swallowed into empty 200s |

**`go test` — 8/8 PASS** (gofmt/vet clean), covering: root body regression; client-route fallback ×3 paths; real-asset passthrough; 301 redirect forwarding; missing-index error surfacing; wrapper unit (200 body forwarded, 302 body forwarded, 404 body suppressed with full-length `n`/nil-err and deferred header); `wroteHeader` guard (302 then 418 → 302 wins, header written once); bare `Write` defaults to 200.
{"model": "deepseek-v4-flash", "problem_class": "go-http-responsewriter-body-discard", "result": "passed", "tests": 8}
Generated from the verified corpus · MIT licensedBack to the catalog