A request-body limit bounds accepted input, not merely the number written in Content-Length. Enforce it while consuming the body, before committing application effects. Compressed input needs separate limits for encoded and decoded bytes. A 413 response is useful only if the expensive allocation or irreversible write has not already happened.

Define which bytes the budget counts

Content-Length describes the framed message body when that framing is used. An HTTP/1.1 chunked request can arrive without a known length. A large declared length permits early rejection, but a missing length does not authorize unlimited reading. Let the HTTP implementation handle message framing, then apply the application’s body budget to its reader.

The body reader excludes HTTP headers and chunk framing overhead. Its limit therefore is not a total network-byte quota. For Content-Encoding: gzip, those body bytes are compressed; the decoded representation can be much larger. Check the encoded stream before decompression and the decoded stream afterward. A small compressed file can exceed a legitimate decoded limit.

Set limits from an endpoint’s contract, including units and supported content codings. A total-body limit also differs from an individual multipart-file or field limit. Streaming avoids buffering everything at once, but does not automatically limit total storage, parser work, or how long a slow sender holds a request.

Validate before making effects visible

Go’s MaxBytesReader reports a MaxBytesError when a read exceeds the configured input limit. By contrast, a plain LimitReader stops with EOF at its boundary; without another check, an application might accept a truncated prefix as complete input. Reading at most the decoded limit plus one byte makes exact-limit acceptance distinguishable from oversize input.

For gzip, successfully reading a prefix does not establish checksum validity. Read through the end before accepting an in-budget representation; closing the decoder alone does not verify its checksum. Keep file publication, database changes, and other effects behind successful complete validation, or stage work with a defined abort path.

When it breaks

Checking only Content-Length misses unknown-length input. Applying the same number only before decompression misses expansion. Parsing first and rejecting afterward spends the resources the limit was meant to protect. Ignoring an error because some bytes were returned can turn a damaged gzip stream into accepted application data.

Size limits do not repair conflicting HTTP framing, authenticate callers, validate file types, or bound aggregate concurrent memory. They also do not stop a sender from dripping a small body forever. Use separate time and concurrency budgets; do not call a byte cap an idle timeout or claim that one rejected request proves resistance to load.

Check it quickly

Save this as body_probe.go and run go run body_probe.go with Go 1.23 or newer. The loopback fixture permits 64 encoded bytes and eight decoded bytes, with no content coding or one gzip coding. These deliberately tiny constants are test contracts, not production recommendations. A counter represents the application’s commit point; no database or uploaded file is written.

package main

import (
	"bytes"
	"compress/gzip"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"runtime"
	"strings"
	"sync/atomic"
	"time"
)

const encodedLimit int64 = 64
const decodedLimit int64 = 8

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func main() {
	var committed, handled atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		defer r.Body.Close()
		w.Header().Set("X-Received-Length", fmt.Sprint(r.ContentLength))
		fail := func(err error) {
			var tooLarge *http.MaxBytesError
			if errors.As(err, &tooLarge) {
				http.Error(w, "encoded limit", http.StatusRequestEntityTooLarge)
			} else {
				http.Error(w, "invalid body", http.StatusBadRequest)
			}
		}
		if r.ContentLength > encodedLimit {
			http.Error(w, "encoded limit", http.StatusRequestEntityTooLarge)
			return
		}
		limited := http.MaxBytesReader(w, r.Body, encodedLimit)
		var reader io.Reader = limited
		coding := strings.TrimSpace(strings.ToLower(strings.Join(r.Header.Values("Content-Encoding"), ",")))
		switch coding {
		case "":
		case "gzip":
			decoded, err := gzip.NewReader(limited)
			if err != nil {
				fail(err)
				return
			}
			defer decoded.Close()
			reader = decoded
		default:
			http.Error(w, "unsupported coding", http.StatusUnsupportedMediaType)
			return
		}
		body, err := io.ReadAll(io.LimitReader(reader, decodedLimit+1))
		if err != nil {
			fail(err)
			return
		}
		if int64(len(body)) > decodedLimit {
			http.Error(w, "decoded limit", http.StatusRequestEntityTooLarge)
			return
		}
		committed.Add(1) // No side effect before the complete body passes validation.
		fmt.Fprint(w, "accepted")
	}))
	server.Config.ReadTimeout = 2 * time.Second
	server.Config.ReadHeaderTimeout = time.Second
	server.Start()
	defer server.Close()
	transport := &http.Transport{}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
	probe := func(label string, payload []byte, coding string, chunked bool, want int) {
		request, err := http.NewRequest(http.MethodPost, server.URL, bytes.NewReader(payload))
		must(err)
		if chunked {
			request.ContentLength = -1
			request.TransferEncoding = []string{"chunked"}
		}
		if coding != "" {
			request.Header.Set("Content-Encoding", coding)
		}
		before := committed.Load()
		response, err := client.Do(request)
		must(err)
		body, readError := io.ReadAll(response.Body)
		closeError := response.Body.Close()
		must(readError)
		must(closeError)
		check(response.Proto == "HTTP/1.1" && response.StatusCode == want, label+": wrong status/protocol")
		wantLength := len(payload)
		if chunked {
			wantLength = -1
		}
		check(response.Header.Get("X-Received-Length") == fmt.Sprint(wantLength), "wrong wire framing")
		delta := int64(0)
		if want == 200 {
			delta = 1
			check(string(body) == "accepted", "wrong successful body")
		}
		check(committed.Load() == before+delta, label+": rejected input caused a side effect")
		fmt.Printf("%s: status=%d committed=%d\n", label, response.StatusCode, committed.Load())
	}
	compress := func(value, name string) []byte {
		var buffer bytes.Buffer
		writer := gzip.NewWriter(&buffer)
		writer.Name = name
		_, err := writer.Write([]byte(value))
		must(err)
		must(writer.Close())
		return buffer.Bytes()
	}
	smallGzip := compress("12345678", "")
	expansion := compress(strings.Repeat("a", 1024), "")
	largeHeader := compress("12345678", strings.Repeat("h", 80))
	check(int64(len(expansion)) <= encodedLimit && int64(len(largeHeader)) > encodedLimit, "gzip fixtures must cross different limits")
	corrupt := append([]byte(nil), smallGzip...)
	corrupt[len(corrupt)-8] ^= 0xff

	probe("exact decoded limit", []byte("12345678"), "", false, 200)
	probe("known oversized", []byte("123456789"), "", false, 413)
	probe("chunked oversized", []byte("123456789"), "", true, 413)
	probe("known encoded limit", bytes.Repeat([]byte("x"), 65), "", false, 413)
	probe("compressed expansion", expansion, "gzip", false, 413)
	probe("exact gzip body", smallGzip, "GZip", false, 200)
	probe("chunked gzip header", largeHeader, "gzip", true, 413)
	probe("bad gzip checksum", corrupt, "gzip", false, 400)
	probe("unsupported coding", []byte("12345678"), "br", false, 415)
	probe("stacked coding", smallGzip, "gzip, gzip", false, 415)
	probe("recovery", []byte("small"), "", false, 200)
	check(handled.Load() == 11 && committed.Load() == 3, "eleven requests must yield three commits")
	fmt.Println(runtime.Version())
}

Eleven requests exercise exact-size plain input, known and chunked oversize input, declared encoded overflow, gzip expansion, exact-size gzip, oversized gzip headers with unknown length, a bad checksum, unsupported coding, stacked coding, and recovery. Only the exact plain body, exact gzip body, and final small body increment the commit counter: three commits total.

Oversize cases return 413, the damaged checksum returns 400, and unsupported or stacked codings return 415. Mixed-case GZip is accepted because coding names are case-insensitive. The server echoes its received ContentLength; unknown-length cases must show -1, so the exercise verifies actual request framing rather than trusting the client-side flag.

The large gzip-header fixture exceeds the encoded limit even though its decoded payload is small. The expansion fixture fits the encoded limit but exceeds the decoded limit. This separates the two budgets instead of treating every 413 as evidence of the same branch.

All assertions passed on macOS with Go 1.27.0. Reads and requests are time-bounded, every response body is closed, and rejection leaves the commit count unchanged. The fixture does not benchmark allocations, inject slow clients, parse multipart or JSON structures, test a reverse proxy, or prove durable transaction behavior.

Further reading

Sources

  • RFC 9110: HTTP semantics, Content-Length, content codings, and 413/415 semantics, checked 2026-09-09.
  • Go net/http, MaxBytesReader, request-body lifecycle, framing, and timeouts, checked 2026-09-09.
  • Go compress/gzip, decoded streams and checksum verification at EOF, checked 2026-09-09.
  • OWASP: File Upload Cheat Sheet, upload budgets, decompression limits, and additional validation boundaries, checked 2026-09-09.