In this post we will review how to limit HTTP request body size in Go using http.MaxBytesReader. We will handle requests with a known Content-Length, requests whose length is unknown, and gzip requests that become much larger after decompression.
A small API can still receive a very large request. If the application reads that request into memory without a limit, one client can consume resources intended for many users. With enough concurrent requests, the result can be an out-of-memory failure and a restarting pod.
The important question is where we enforce the limit. Checking a header is useful, but the application must also limit the bytes it actually reads.
The Content-Length Problem
Assume our API accepts JSON documents up to one MiB, or 1,048,576 bytes.
A tempting implementation is:
if request.ContentLength > maxBodyBytes {
http.Error(writer, "request body too large", http.StatusRequestEntityTooLarge)
return
}
This can reject a known oversized request before parsing it. However it is not sufficient by itself.
An HTTP/1.1 client can send a body using chunked transfer encoding, without a Content-Length header. In Go, an unknown request body length is represented by ContentLength equal to -1. HTTP/2 requests can also arrive without a known length, although HTTP/2 does not use HTTP/1.1 chunked transfer encoding.
A negative length passes the check above. If we then call io.ReadAll(request.Body), the application continues allocating memory while the client sends data.
The limit must apply to the reader, including when the length is unknown.
Use http.MaxBytesReader
Go provides a standard library helper for this purpose:
request.Body = http.MaxBytesReader(writer, request.Body, maxBodyBytes)
The replacement reader reports a *http.MaxBytesError when reading would exceed the configured limit. We can identify that error with errors.As and return HTTP 413 Content Too Large.
Wrapping the reader does not load the body or check it immediately. The limit is enforced as downstream code reads it.
This distinction matters. If a handler reads only the first few bytes, it has not established that the entire body fits. If an endpoint requires a complete document, read and validate the complete bounded document before performing its business operation.
Also notice that io.LimitReader is a different tool. It returns EOF at its limit, which can make a truncated input look like a complete input. A size check built with it normally needs to allow one extra byte and explicitly detect overflow. MaxBytesReader provides the HTTP-specific error directly.
Gzip Changes What We Need to Count
Assume a client compresses a large JSON document before sending it. The compressed body could be small enough to pass an ingress limit while the decompressed document is far above the API's allowed size.
For a gzip request, Content-Length describes the compressed representation. It does not tell us how many bytes the application will receive from the gzip reader.
Go's HTTP server does not automatically decompress a request just because it has Content-Encoding: gzip. Our code or framework middleware must do that explicitly.
For an API that accepts compressed input, use two limits:
incoming body
-> compressed/input byte limit
-> gzip decompression, when requested
-> decompressed byte limit
-> parsing
-> business operation
The input limit bounds bytes read before decompression. The decompressed limit bounds bytes delivered to the application after expansion. They can have different values.
For an uncompressed request, the same sequence works without the decompression step.
A Complete Go Example
The following server accepts one JSON document at POST /document. It supports an absent Content-Encoding or a single gzip encoding, and rejects other encodings with HTTP 415.
The example uses only the standard library. Save the following as main.go.
package main
import (
"compress/gzip"
"encoding/json"
"errors"
"flag"
"io"
"log"
"net/http"
"strings"
"time"
)
type document struct {
MessageText string `json:"message"`
}
type bodyLimits struct {
inputBytes int64
decodedBytes int64
}
func (b *bodyLimits) readBody(
writer http.ResponseWriter,
request *http.Request,
) ([]byte, error) {
request.Body = http.MaxBytesReader(writer, request.Body, b.inputBytes)
defer request.Body.Close()
var reader io.Reader = request.Body
if strings.EqualFold(strings.TrimSpace(request.Header.Get("Content-Encoding")), "gzip") {
compressed, err := gzip.NewReader(request.Body)
if err != nil {
return nil, err
}
defer compressed.Close()
reader = compressed
}
decoded := http.MaxBytesReader(writer, io.NopCloser(reader), b.decodedBytes)
defer decoded.Close()
return io.ReadAll(decoded)
}
func writeReadError(
writer http.ResponseWriter,
err error,
) {
var limitError *http.MaxBytesError
if errors.As(err, &limitError) {
http.Error(writer, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(writer, "cannot read request body", http.StatusBadRequest)
}
func (b *bodyLimits) handleDocument(
writer http.ResponseWriter,
request *http.Request,
) {
if request.Method != http.MethodPost {
writer.Header().Set("Allow", http.MethodPost)
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
return
}
encodings := request.Header.Values("Content-Encoding")
encoding := strings.TrimSpace(request.Header.Get("Content-Encoding"))
if len(encodings) > 1 || (encoding != "" && !strings.EqualFold(encoding, "gzip")) {
http.Error(writer, "unsupported content encoding", http.StatusUnsupportedMediaType)
return
}
if request.ContentLength > b.inputBytes {
http.Error(writer, "request body too large", http.StatusRequestEntityTooLarge)
return
}
data, err := b.readBody(writer, request)
if err != nil {
writeReadError(writer, err)
return
}
var value document
if err := json.Unmarshal(data, &value); err != nil {
http.Error(writer, "invalid JSON document", http.StatusBadRequest)
return
}
// Perform the business operation only after reading and validating the body.
writer.WriteHeader(http.StatusNoContent)
}
func main() {
inputBytes := flag.Int64("max-input-bytes", 1<<20, "maximum input body bytes")
decodedBytes := flag.Int64("max-decoded-bytes", 1<<20, "maximum decompressed body bytes")
flag.Parse()
if *inputBytes <= 0 || *decodedBytes <= 0 {
log.Fatal("body limits must be positive")
}
limits := &bodyLimits{inputBytes: *inputBytes, decodedBytes: *decodedBytes}
mux := http.NewServeMux()
mux.HandleFunc("/document", limits.handleDocument)
server := &http.Server{
Addr: "127.0.0.1:8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
Run the server:
go run main.go -max-input-bytes 1048576 -max-decoded-bytes 1048576
The Content-Length check is an early rejection for the input limit. The readers enforce the actual limits for known and unknown lengths.
Reading through the decompressed reader to EOF also checks that a gzip stream finishes correctly. A malformed or truncated stream returns HTTP 400. If either reader reports MaxBytesError, the response is HTTP 413.
json.Unmarshal checks the complete buffered document, so a valid JSON object followed by another object or unexpected trailing data is rejected. Ordinary trailing JSON whitespace is accepted, but still counts toward the byte limit.
Notice that the handler does not send success before reading the body. Once a response has started, changing its status may be too late. This is also discussed in HTTP Streaming Error Handling in GO: Why HTTP 200 Is Not Enough
Try Small and Oversized Requests
A small document should return HTTP 204:
curl -i -H 'Content-Type: application/json' \
--data-binary '{"message":"hello"}' \
http://127.0.0.1:8080/document
Create a JSON document with a two-MiB message:
python3 - <<'PY'
import json
with open("large.json", "w") as output:
json.dump({"message": "x" * (2 * 1024 * 1024)}, output)
PY
Send it with its known length:
curl -i -H 'Content-Type: application/json' \
--data-binary @large.json \
http://127.0.0.1:8080/document
The server should return HTTP 413. To exercise HTTP/1.1 chunked input, send the same document without Content-Length:
curl --http1.1 -i \
-H 'Content-Type: application/json' \
-H 'Transfer-Encoding: chunked' -H 'Content-Length:' \
--data-binary @large.json \
http://127.0.0.1:8080/document
The reader still rejects it. Chunk boundaries do not reset the byte counter.
Now compress the document:
gzip -c large.json > large.json.gz
wc -c large.json large.json.gz
Send the compressed version:
curl -i -H 'Content-Type: application/json' \
-H 'Content-Encoding: gzip' \
--data-binary @large.json.gz \
http://127.0.0.1:8080/document
Although this repetitive document compresses very well, the decompressed limit should still produce HTTP 413.
Middleware Order and Error Handling
In a framework such as Echo, place the input limit before request decompression and the decoded limit after it. Check the framework's actual execution order: middleware registration order alone can be misleading when wrappers are nested.
Both limits must run before body logging, parsing, or any other component that buffers the body. A limiter installed after io.ReadAll cannot undo the allocation.
Also make sure size errors survive the framework's error handling. A parser that converts every read error into a string can lose the MaxBytesError type. The application might then return HTTP 400 or 500 instead of 413.
Preserve wrapped errors where possible. If an existing abstraction cannot preserve them, the limiting reader can record that overflow occurred, and the handler boundary can inspect that state before writing its response. Do not identify overflow by matching an error message.
The sample keeps error handling directly beside the read, so it does not need that extra state.
A Body Limit Is One Part of the Memory Budget
The example buffers a bounded body for simplicity. This does not mean that one MiB of accepted JSON requires only one MiB of memory.
The buffer can have spare capacity. JSON decoding creates additional values, and request handling retains other state. Multiple simultaneous requests multiply those costs.
Keep the maximum request size configurable, choose limits for each endpoint's real needs, and also bound active requests and waiting work. I covered that part in Go Concurrency Limits: Prevent Goroutine Explosions with Backpressure
Streaming parsing can reduce buffering for large documents, but it still needs byte limits, validation, and a clear decision about when side effects are allowed. One successful json.Decoder.Decode call does not prove the body ended; a single-document API must also check what follows.
If you need to understand which JSON fields consume the space, see JSON Size Analysis
Size Limits and Deployment Limits
An ingress or reverse proxy body limit is useful because it can reject traffic before it reaches the service. Keep application limits as well: internal callers may reach the service directly, and a proxy might only see the compressed representation.
A byte limit does not impose a time limit. A client can send an allowed body very slowly. The example includes server timeouts; production values should reflect expected request sizes, upload speeds, and proxy behavior. Decompression also consumes CPU, so byte limits do not replace concurrency control or protection at the network edge.
For multipart uploads, apply an overall body limit before parsing, then add the required file and field limits. A multipart memory threshold alone is not a total upload-size limit; parsers can spill file data to disk.
Monitor 413 responses by endpoint and check that valid clients still fit. Avoid logging complete rejected bodies, which creates another resource problem and can expose sensitive data.
Verify the Boundaries
Before using the limit, check an empty body, valid small JSON, malformed JSON, and valid JSON followed by a second document. Check a body exactly at the limit and another one byte above it.
Repeat the size cases with known lengths, unknown lengths, and gzip input. Check truncated gzip data and unsupported Content-Encoding values. For oversized input, verify HTTP 413 and verify that the business operation never runs.
If a reader or middleware changes, repeat these checks through the actual handler chain. Testing a size helper in isolation will not catch a decompression layer or error wrapper installed in the wrong place.
Final Note
Use Content-Length for an early rejection, then enforce the real limit while reading. If requests can be compressed, bound both the incoming representation and the decompressed content.
Read and validate the required document before performing its operation, preserve size errors through middleware, and combine request limits with concurrency and time limits. This gives an API a predictable response to oversized input instead of an unpredictable memory failure.

No comments:
Post a Comment