While testing an internal HTTP service against Go 1.27, a handler that had worked for years started reporting errors from an unlikely place: r.Body.Close().

The handler does what any length-aware decoder does — it reads exactly the payload it expects and stops, without reading far enough to observe io.EOF. Then it closes the body. On Go 1.26 that Close returned nil. On 1.27 it returns io.EOF.

Pinning it down

Timer-driven HTTP repros are flaky, so I drove net/http over a net.Pipe with a one-shot listener: fully deterministic, no real sockets, no timeouts. The whole thing fits in a playground link. A handler reads 2 of 4 body bytes, closes, and reports the error:

req.Body.Close() = EOF
want               <nil>

Bisecting pointed at CL 794640, “net/http: don’t rely on server request body not changing”, which consolidated server-side body draining into (*body).Close. The old drain path cleared the sentinel after a successful drain:

n, err = io.CopyN(io.Discard, bodyLocked{b}, maxPostHandlerReadBytes)
if err == io.EOF {
    err = nil
}

The refactor kept the drain, improved the bookkeeping — and dropped the err = nil. Draining to EOF is the success path here, but the sentinel now leaks out of Close. The CL message describes a pure refactor, so the behavior change looks unintended.

Upstream

Filed as golang/go#80964, fix sent as golang/go#80968: restore the clear inside the new EOF branch, keeping the CL’s maxPostHandlerReadBytes+1 read bound and the earlyClose/sawEOF bookkeeping intact. One line, plus a regression test.

Takeaway

Close errors are contract, even when nobody documents them. A refactor that only “moves code around” can still change what callers observe on the error path — and somewhere, a decoder that trusts its Content-Length will notice.