A team of six reviewing an API request validation workflow during a project presentation.

Go Decoded the Request. Did It Consume the Whole JSON Body?

Tue, Oct 20, 2026

Go Decoded the Request. Did It Consume the Whole JSON Body?

A successful Decode can be the most misleading green signal in a JSON write handler. The call may have produced the struct you expected, passed an unknown-field check, and left you holding a plausible request_id. None of those observations proves that the HTTP body contains exactly one JSON value. Go documents Decoder.Decode as reading the next JSON-encoded value, while RFC 8259 defines a JSON text as whitespace, one value, then whitespace. Those are compatible contracts, but they answer different questions. Decoder.Decode reads the next value; it does not by itself certify the entire request as a single JSON text.

This playbook qualifies one deliberately small write endpoint. It uses the legacy encoding/json decoder, a 128-byte incoming-body limit, one required nonblank request_id, and an inert integer increment standing in for the first application mutation. There is no live listener, database, external service, authentication layer, NDJSON mode, JSON v2 migration, duplicate-key policy, or response-download limit in scope.

The release question is narrower and operational: after the first decode succeeds, may the handler perform its first side effect? The answer must come from three pieces of evidence: the same decoder’s second result, the request-body size status, and the write count. The results below were independently reproduced on 20 October 2026 using go1.23.2 on linux/amd64 with GOTOOLCHAIN=local. The complete fixture returned PASS_14_CASES; its source digest and file-encoding convention appear with the harness.

Define what this endpoint accepts before decoding it

Start with the application contract. This endpoint accepts exactly one non-null JSON object that populates RequestID with a nonblank string through Go’s struct-field matching rules. It rejects unmatched object keys, requires the complete admitted request body to be at most 128 bytes, and must not cross its first mutation boundary until every admission check has passed. The request_id tag does not impose exact letter case; the legacy decoder also accepts case-insensitive matches.

RFC 8259 is broader. A JSON value may be an object, array, number, string, true, false, or null, and a complete JSON text is ws value ws. The RFC’s whitespace set is specifically space, horizontal tab, line feed, and carriage return. Therefore “object only,” “non-null,” “must have request_id,” and “128 bytes maximum” are endpoint policy; they are not properties of JSON grammar itself. RFC 8259’s JSON grammar supplies the syntax baseline against which the narrower endpoint contract is defined.

The fixture makes the mutation boundary visible with writes++. That counter is an inert sentinel: it models the moment after which a real handler might insert a row, publish an event, or change state, but this laboratory performs none of those actions. HTTP 400, 413, and 204 are likewise local policy choices for the experiment, not a claim that every API must use those statuses in every comparable design.

Write that contract into the review ticket before evaluating code. A reviewer should be able to answer five questions without reading implementation details: what root shape is allowed, what makes the object semantically acceptable, what may follow it, what the admitted-body size limit is, and which line represents the first side effect. If any answer is missing, ACCEPT is premature because the candidate lacks the policy needed to interpret a green test.

This request-admission question also stays separate from stack selection. The adjacent Go API development foundations article can frame broader language and API work; here the stack is fixed to Go’s standard library and the review target is one mutation gate.

Separate a decoded value from an admitted body

Treat three review surfaces as different artifacts. First is the decoded value: can the decoder populate Request? Second is the object contract: does the decoded object satisfy field and business rules? Third is the request envelope: after that object, is there only permitted JSON whitespace and then end of input? A handler that proves only the first two can still admit a body that violates the third.

The distinction follows directly from the documented decoder contract. Decode reads the next JSON-encoded value from its input. NewDecoder also introduces its own buffering and may read beyond the value requested. That means the underlying io.Reader position is not a reliable substitute for the decoder’s logical state; unread logical input may already sit inside the decoder.

For a single-value HTTP endpoint, reuse the same decoder and ask it for another value. The required success condition for the suffix is not “no second object” and not “some boolean says false.” It is io.EOF. Go documents io.EOF as the graceful signal that no more input is available; unexpected termination of structured data should surface as another error, not graceful EOF.

This is also why the mutation boundary belongs after the suffix check. A successful first decode establishes a candidate value. It does not authorize a write.

The distinction matters even when the first value is perfect. Imagine reviewing only the struct after G03: it has the right field, the right value, and no unknown member. Nothing about that in-memory value reveals the adjacent second object. Request admission therefore cannot be reconstructed from the first object after the fact. The evidence has to be gathered while the decoder still owns the input boundary and before downstream code treats the candidate as committed work.

Keep member ambiguity outside this experiment

Legacy JSON handling has other permissive behaviors, but they are separate from trailing top-level input. For an adjacent example, see duplicate-key validation in Python. Keep that concern separate from this experiment. Likewise, DisallowUnknownFields is narrow: Go documents it as causing an error when decoding to a struct and an input object contains keys that do not match non-ignored exported fields. It is not a complete “strict JSON” mode, an authentication check, or a whole-request consumption proof.

The operational benefit of this separation is reviewability. When the question is “can we mutate after the first decode?”, the evidence should isolate that boundary instead of mixing in duplicate names, UTF-8 normalization, schema-draft behavior, or caller identity.

Pin a small HTTP harness that cannot mutate production

The harness uses httptest.NewRequest and httptest.NewRecorder, so every case remains in memory. Go documents NewRequest as creating an incoming server request suitable for handler testing, and ResponseRecorder as an http.ResponseWriter implementation that records response changes for later inspection. The separate writes counter records the application-mutation sentinel. There is no httptest.Server in the fixture, so no TCP listener is started. That keeps the experiment focused on request-body admission rather than transport scheduling, production middleware, database behavior, or retry semantics.

Record enough provenance to make a rerun meaningful. The fixture emits runtime.Version(), runtime.GOOS, runtime.GOARCH, the body limit, each case identifier, body length, body SHA-256, both handler results, and the supplemental buffering probe. The source file is named json_body.go. Saved as UTF-8 with the displayed indentation, LF line endings, and one final newline, it contains 4,991 bytes and has this SHA-256 digest:

a7ba282a9f828f910f30dbc6beb096b02a0c11253a4220c64571301b68584fd8  json_body.go

writes needs an equally precise definition. A value of 0 means the handler returned before the inert mutation sentinel. A value of 1 means the candidate crossed that boundary. It does not mean a database transaction committed, a message was published, or an idempotency key was persisted. Those systems do not exist in this fixture.

A useful harness also carries both a positive and a negative control. Here G01/G02 prove the candidate can accept the intended grammar, while G03/G04/G05/G06/G12/G14 prove that first-decode-only admission crosses the sentinel under disallowed suffixes. That contrast is more informative than a suite containing only rejected malformed requests: it demonstrates that the faulty candidate is not merely buggy in theory but reaches the exact operational boundary the review is meant to protect.

The run is intentionally bounded and local:

GOTOOLCHAIN=local go run json_body.go > go-result.json

The independently reproduced run used go1.23.2 on linux/amd64, exited with status 0, and returned PASS_14_CASES. A future reviewer must record the environment actually used rather than copying that statement into a new release record. The fixture uses only the standard library and an inert side effect; it does not exercise real-server deadlines, sockets, proxies, request cancellation, or deployment policy.

Run the complete first-decode and whole-body comparison

The two candidate paths share the same request construction, 128-byte body wrapper, destination type, DisallowUnknownFields, non-null check, nonblank request_id rule, rejection function, and side-effect sentinel. They differ in one place: the guarded path calls the same decoder again and requires io.EOF before validating the final application object and incrementing writes.

Save the complete fixture below as json_body.go. The source is intentionally complete; deleting negative controls or replacing the second decode with a different reader would change the experiment being reviewed.

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "os"
    "runtime"
    "strings"
)
const bodyLimit int64 = 128
type Request struct {
    RequestID string json:"request_id"
}
type Result struct {
    Status int json:"status"
    Writes int json:"writes"
}
type Case struct {
    ID, Body              string
    StrictWant, NaiveWant int
}
func runHandler(body string, requireEOF bool) Result {
    req := httptest.NewRequest(http.MethodPost, "http://example.test/jobs", strings.NewReader(body))
    recorder := httptest.NewRecorder()
    writes := 0
    req.Body = http.MaxBytesReader(recorder, req.Body, bodyLimit)
    defer req.Body.Close()
    reject := func(err error) {
        var tooBig http.MaxBytesError
        status := http.StatusBadRequest
        if errors.As(err, &tooBig) {
            status = http.StatusRequestEntityTooLarge
        }
        recorder.WriteHeader(status)
    }
    dec := json.NewDecoder(req.Body)
    dec.DisallowUnknownFields()
    var value Request
    if err := dec.Decode(&value); err != nil {
        reject(err)
        return Result{recorder.Code, writes}
    }
    if requireEOF {
        var extra any             // A second null is still an extra JSON value.
        err := dec.Decode(&extra) // Reuse this decoder, including its buffer.
        if err != io.EOF {
            if err == nil {
                err = errors.New("extra top-level JSON value")
            }
            reject(err)
            return Result{recorder.Code, writes}
        }
    }
    if value == nil || strings.TrimSpace(value.RequestID) == "" {
        reject(errors.New("non-null object with request_id required"))
        return Result{recorder.Code, writes}
    }
    writes++ // Inert side-effect sentinel; only reached after admission.
    recorder.WriteHeader(http.StatusNoContent)
    return Result{recorder.Code, writes}
}
func must(ok bool, message string) {
    if !ok {
        panic(message)
    }
}
func main() {
    good := {"request_id":"job-A"}
    cases := []Case{
        {"G01_object", good, 204, 204},
        {"G02_JSON_whitespace", " \t\r\n" + good + " \t\r\n", 204, 204},
        {"G03_second_object", good + {"request_id":"job-B"}, 400, 204},
        {"G04_second_null", good +  null, 400, 204},
        {"G05_second_number", good +  7, 400, 204},
        {"G06_malformed_tail", good +  garbage, 400, 204},
        {"G07_top_level_null", null, 400, 400},
        {"G08_unknown_field", {"request_id":"job-A","debug":true}, 400, 400},
        {"G09_empty", "", 400, 400},
        {"G10_incomplete_object", {"request_id":, 400, 400},
        {"G11_oversized_first", {"request_id":" + strings.Repeat("x", 180) + "}, 413, 413},
        {"G12_oversized_whitespace_tail", good + strings.Repeat(" ", 180), 413, 204},
        {"G13_array_not_object", [{"request_id":"job-A"}], 400, 400},
        {"G14_unmatched_closer", good + ], 400, 204},
    }
    rows := make([]map[string]any, 0, len(cases))
    for , c := range cases {
        strict, naive := runHandler(c.Body, true), runHandler(c.Body, false)
        must(strict.Status == c.StrictWant && naive.Status == c.NaiveWant, c.ID+" status")
        for , result := range []Result{strict, naive} {
            wantWrites := 0
            if result.Status == 204 {
                wantWrites = 1
            }
            must(result.Writes == wantWrites, c.ID+" side-effect sentinel")
        }
        digest := sha256.Sum256([]byte(c.Body))
        rows = append(rows, map[string]any{"id": c.ID, "body_bytes": len(c.Body),
            "body_sha256": hex.EncodeToString(digest[:]), "single_value_gate": strict,
            "first_decode_only": naive})
    }
    // Supplemental read-ahead/More diagnostic, not another HTTP case.
    source := strings.NewReader(good + ])
    decoder := json.NewDecoder(source)
    var first Request
    must(decoder.Decode(&first) == nil, "buffer probe first decode")
    remaining := source.Len()
    more := decoder.More()
    buffered, err := io.ReadAll(decoder.Buffered())
    must(err == nil, "read buffered diagnostic")
    var extra any
    tailErr := decoder.Decode(&extra)
    must(remaining == 0 && !more && string(buffered) == "]", "recorded strings.Reader behavior changed")
    must(tailErr != nil && tailErr != io.EOF, "invalid closer must not look like EOF")
    report := map[string]any{"status": "PASS_14_CASES", "go": runtime.Version(),
        "os": runtime.GOOS, "arch": runtime.GOARCH, "body_limit": bodyLimit, "cases": rows,
        "supplemental_buffer_probe": map[string]any{"underlying_remaining": remaining,
            "More": more, "buffered": string(buffered), "tail_error": tailErr.Error()}}
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ")
    if err := encoder.Encode(report); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The full ledger matters because the control cases stop us from overfitting a fix to “two objects.” Both candidates are exercised against the same synthetic bodies, so a difference in status or write count can be attributed to the EOF gate rather than to a different schema, reader, body limit, or business rule. That is the key experimental control. The following results were independently reproduced from the displayed source on 20 October 2026:

Case

First Decode only: status / writes

Single-value gate: status / writes

G01_object

204 / 1

204 / 1

G02_JSON_whitespace

204 / 1

204 / 1

G03_second_object

204 / 1

400 / 0

G04_second_null

204 / 1

400 / 0

G05_second_number

204 / 1

400 / 0

G06_malformed_tail

204 / 1

400 / 0

G07_top_level_null

400 / 0

400 / 0

G08_unknown_field

400 / 0

400 / 0

G09_empty

400 / 0

400 / 0

G10_incomplete_object

400 / 0

400 / 0

G11_oversized_first

413 / 0

413 / 0

G12_oversized_whitespace_tail

204 / 1

413 / 0

G13_array_not_object

400 / 0

400 / 0

G14_unmatched_closer

204 / 1

400 / 0

Use a side-effect sentinel after the final gate

The acceptance proof is not merely that the guarded path returns the expected status. Every rejected guarded case must also show writes: 0. The first-only candidate intentionally violates that invariant for G03, G04, G05, G06, G12, and G14: it returns 204 and reaches one write even though the complete body violates this endpoint’s contract.

That is why PASS_14_CASES is a diagnostic success, not an approval of both implementations. The fixture passes because it observes the expected contrast. Release approval belongs only to the path whose accepted and rejected rows match the declared contract and whose rejection rows cannot cross the sentinel.

Start with one object and permitted trailing whitespace

G01 and G02 are positive controls. Without them, a “strict” implementation could look safe simply because it rejects everything. G01 proves that the ordinary object {"request_id":"job-A"} reaches 204 / 1. G02 wraps the same object in space, tab, carriage return, and line feed and also reaches 204 / 1 on both candidates.

That behavior is exactly what the endpoint contract intends. RFC 8259 permits those four whitespace characters around a JSON value, and its complete-text grammar is ws value ws. Do not silently widen “JSON whitespace” to every Unicode character that an application might regard as blank; the RFC rule used here is the four-character set in its grammar.

G09 and G07 provide useful contrasts. The empty body fails before there is any value to decode: the decoder cannot produce the required object, so the handler returns 400 / 0. A top-level null, by contrast, is valid JSON syntax. It fails because the endpoint requires a non-null object. Those are different failure classes even though this laboratory maps both to HTTP 400.

The pointer declaration makes the distinction observable:

var value Request
if err := dec.Decode(&value); err != nil { / reject / }
if value == nil { / reject */ }

Go’s documented conversion rules say that unmarshaling JSON null into a pointer sets the pointer to nil; for a non-null value, a nil pointer is allocated as needed and decoding proceeds into the pointed-to value. Thus null can produce a successful first Decode while still failing the endpoint’s non-null-object policy.

The control logic is important operationally. The suffix gate is not a replacement for object validation. It only proves that the candidate value occupies the complete JSON text, apart from permitted trailing whitespace.

Positive controls also protect against an overzealous repair. A team could make every trailing-data test green by rejecting all non-empty bodies or by forbidding legal JSON whitespace after the object. G01 and G02 prevent that false victory. The repaired handler must become narrower only where the endpoint contract is narrow: it rejects additional top-level material and limit overruns while preserving one object surrounded by the RFC-defined whitespace the contract permits.

Reject a second value even when it is null

G03, G04, and G05 are the central counterexamples. Each begins with the same acceptable object. The suffix is, respectively, another object, null, and the number 7. The first-only candidate accepts all three because its job ends after decoding the first value. The single-value gate rejects all three because the second call does not return EOF.

The second destination is deliberately any:

var extra any
err := dec.Decode(&extra)

That choice prevents unnecessary root-type filtering from obscuring the whole-body check. Go’s conversion rules for an interface destination permit ordinary JSON booleans, numbers, strings, arrays, objects, and null; a successfully decoded null becomes a nil interface value. Therefore the condition must inspect err, not the decoded value. A nil extra is not proof that there was no second value: G04 is precisely a valid second value whose representation is nil.

The gate classifies the second call by its error result rather than by the contents of extra. A reviewer can read one branch and see that the only accepted suffix result is normal end of input. There is no special exemption for null, zero, false, an empty array, or an empty object.

The admission predicate is consequently exact:

if err != io.EOF {
    // nil means a complete extra value; non-nil/non-EOF means another failure.
    reject(err)
    return
}

The complete fixture wraps the nil-error case with errors.New("extra top-level JSON value") so the local rejection function receives a non-nil error. The semantic distinction remains: err == nil means another value decoded successfully; err == io.EOF means graceful end of input; any other error prevents admission. io.EOF is explicitly the graceful no-more-input signal.

Do not use a type error as your only suffix test

The destination type does not make the suffix gate correct by itself. A second decode into map[string]any can also enforce the boundary when every result other than io.EOF is rejected. It adds type mismatches for scalar and array values, while JSON null still decodes successfully into a nil map. The error comparison, rather than whether the destination contains a value, establishes the input boundary.

Using any avoids those unnecessary root-type restrictions for ordinary representable JSON values. Conversion errors remain possible, including when a valid number such as 1e1000 exceeds the default float64 range. That changes the diagnostic, not the admission decision: a successful extra decode and any non-EOF error both reject the request. A malformed tail is also rejected. Only EOF authorizes progress.

Treat malformed suffixes differently from normal EOF

G06 adds garbage after the good object. G14 appends an unmatched ]. Neither suffix is a valid second JSON value, but both still violate the endpoint contract because neither is permitted trailing JSON whitespace followed by end of input.

This is where “second decode must fail” is too weak. Both malformed suffixes do fail, but failure is not the acceptance condition. Only the specific normal-end result, io.EOF, is acceptable. Go’s EOF documentation distinguishes graceful end from unexpected termination or other structured-data errors, and the decoder documentation says Decode reads the next JSON value. The gate therefore treats malformed syntax and successful extra-value decoding as two different reasons for the same local outcome: refuse the request before mutation.

In the reproduced go1.23.2 probe, the unmatched closer produced this diagnostic after the first object:

invalid character ']' looking for beginning of value

That string is useful evidence for this recorded runtime; it should not become a production policy key. Branching on human-readable parser messages would tie behavior to wording that the endpoint contract does not require. The robust policy branches on categories available to the code: exact EOF, *http.MaxBytesError, successful extra decode, or another decoding error.

The first-only candidate demonstrates why malformed tails deserve explicit negative controls. It reaches 204 / 1 for G06 and G14 even though the complete body is not a valid single JSON text under the endpoint policy. A schema check on the first object cannot rescue that because the defect is outside the first value.

Inspect decoder read-ahead before trusting the underlying reader

The supplemental probe exists to prevent a common but structurally wrong shortcut: checking whether the underlying reader has bytes left after the first decode. Go explicitly documents that a decoder introduces its own buffering and may read from the underlying reader beyond the JSON value requested. It also documents Buffered() as exposing data still held in that decoder buffer until the next decode.

The probe uses strings.NewReader(good + "]"). After the first object decodes successfully on the reproduced go1.23.2 linux/amd64 run, four observations coexist:

Probe field

Recorded value

underlying_remaining

0

More

false

buffered

]

second Decode

syntax error, not io.EOF

The reader itself reports no remaining bytes because the decoder has already read the closing bracket into its own buffer. Yet the logical JSON stream is not finished successfully: Buffered() still exposes ], and the next Decode rejects it. This is exactly why “underlying reader exhausted” is not equivalent to “decoder proved end of input.”

Do not generalize the observed amount of read-ahead. strings.Reader, this short body, this runtime, and this decoder implementation produced the recorded values. The documented point is only that decoder-owned buffering exists and may extend beyond the requested value. The safe review rule is independent of any assumed buffer size: continue asking the same decoder to establish EOF.

Reusing the same decoder is simpler to review for this endpoint than manually concatenating dec.Buffered() with the underlying reader. A correctly implemented check of that reconstructed tail can also be sound, but it introduces another input-management mechanism. The declared single-value contract can be checked by the decoder that already parsed the first value. Keeping one decoder responsible for both the candidate and the suffix reduces the number of assumptions a reviewer must validate.

More is not a top-level end-of-body predicate

Decoder.More() is another tempting shortcut because it returns a boolean. Its documented meaning, however, is whether there is another element in the current array or object being parsed. It is not documented as a request-body exhaustion predicate.

The probe makes the mismatch concrete: More is false while ] remains buffered and a second decode returns a syntax error. That does not mean More is broken; it means the question being asked is outside More’s contract. At top level, require the decoder result that directly represents end of input: EOF.

Keep root shape and unknown fields as independent gates

G07 through G10 and G13 show why whole-body consumption is necessary but not sufficient. A handler can prove there is exactly one JSON value and still need to reject that value for shape, field, or completeness reasons.

G07 is syntactically valid null. As noted earlier, decoding into *Request leaves the pointer nil, so the explicit value == nil policy rejects it. G13 is a JSON array; an array is legal JSON, but it is not assignable to the endpoint’s struct destination, so decoding reports a type mismatch rather than producing an acceptable object. Go documents both pointer/null handling and UnmarshalTypeError for JSON values that are inappropriate for a destination type.

G08 is a valid object with request_id plus debug. Here DisallowUnknownFields performs one local job: when the destination is a struct, an object key with no matching non-ignored exported field causes a decoder error. Without that method, legacy decoding ordinarily ignores unmatched object keys. Go’s struct-field matching prefers an exact match but also accepts case-insensitive matches, so this method does not enforce exact lowercase key spelling.

G09 contains no value, and G10 begins an object member but stops before supplying its value. Both fail during the first decode. The request never reaches the suffix gate, object policy, or sentinel.

Finally, the nonblank identifier rule is application logic after syntactic and whole-body admission:

if value == nil || strings.TrimSpace(value.RequestID) == "" {
    reject(errors.New("non-null object with request_id required"))
    return
}

Nothing in this fixture turns that one field check into a general business-schema validator. It is the minimum object policy required by the example endpoint. The fourteen-case table focuses on body admission; an endpoint’s wider regression suite should also exercise missing, empty, whitespace-only, null, and incorrectly typed identifier values.

Choose the rejection policy before any application mutation

A release review goes faster when status behavior and mutation behavior are declared before anyone looks at the test output. For this laboratory, the policy is compact:

Request condition

Local HTTP policy

Required writes

Exactly one admitted object, valid request_id, within limit

204

1

Malformed JSON, extra top-level value, malformed tail, wrong root shape, unknown field, empty body, missing/blank ID

400

0

Body-limit overrun detected while proving admission

413

0

The table is an application-policy choice, not a universal HTTP taxonomy. Another service might choose different error envelopes or status details while preserving the essential safety property: no application mutation until request admission is complete.

The mutation line belongs after every gate whose failure is supposed to mean “the request did not happen.” In this fixture that ordering is: wrap the body; configure the decoder; decode the candidate; prove EOF with the same decoder; validate non-null object and request_id; then increment the sentinel. A real application would place its transaction start, durable insert, event publication, or other first irreversible effect at the equivalent boundary appropriate to its design.

Keep that concern distinct from JSON Schema extension boundaries. A schema can describe fields inside a value; it does not by itself prove that the transport body contains no second top-level value after the value it validated.

The review invariant is stronger than “we returned an error eventually.” Every rejected case must terminate with zero writes. Once a mutation can precede the suffix proof, later rejection no longer repairs the admission mistake.

Bound the read needed to prove the suffix is empty

Whole-body validation has a resource consequence: to establish that nothing but permitted whitespace follows the first value, the handler may need to read beyond the first value. That read must itself be bounded.

The fixture uses http.MaxBytesReader with a 128-byte limit. The Go 1.23.2 documentation describes it as an incoming-request-body limiter, similar to io.LimitReader but returning a ReadCloser and a non-nil *MaxBytesError when a read exceeds the limit. MaxBytesError carries the configured limit. The limit bounds the bytes exposed through this reader; the Go 1.23.2 implementation can consume one additional underlying byte to detect an overrun. It is therefore an admitted-body limit, not a claim that total underlying observation stops at exactly 128 bytes.

G11 and G12 expose two different ways the limit interacts with admission. G11’s first object is itself oversized. Both candidates encounter the limit while trying to decode that object, return 413, and keep writes at zero. G12 begins with the small valid object but appends 180 spaces. The first-only candidate returns 204 / 1; it never requires the evidence needed to prove that the trailing region is acceptable within the body limit. The single-value gate continues the same decoder, the runtime surfaces *http.MaxBytesError, and the handler returns 413 / 0.

That G12 result is not merely a size-test curiosity. It shows that the end-of-input proof and the byte cap are coupled: a handler cannot claim “only whitespace remains” without performing the bounded read that establishes where the request ends.

This is also why the body limit must wrap the reader before decoder construction. The decoder’s first and second calls then observe the same bounded source, so no suffix-validation branch can accidentally bypass the limit. The evidence chain stays linear: the request enters through one limiter, one decoder evaluates both values and tail, and one mutation sentinel remains unreachable until both the structural and size conditions are resolved.

This is an incoming-body concern, not the response-side LimitReader problem. For broader backend resource and performance considerations, keep this gate in its lane: it limits request-body reads needed for admission and says nothing about caches, database latency, response payloads, or service throughput.

Keep size rejection distinct from end-of-input approval

*http.MaxBytesError is not EOF. A wrapper that merely truncates the input at 128 bytes and then presents successful EOF would destroy the distinction between “the client ended here” and “our local cap stopped observation here.” MaxBytesReader is useful precisely because a read beyond the cap can surface as an error rather than masquerading as normal completion.

A body limit is also not a read deadline. This in-memory harness has no slow client and no socket. Real HTTP-server deadline and cancellation policy remain separate deployment controls. Likewise, the 128-byte cap is not a claim about total process allocation or every transport read. The experiment bounds the bytes exposed through the request-body limiter; the decoder, recorder, application, and transport have other resource costs.

Finally, do not require every malformed long body to return 413. If a decoder can already reject malformed syntax before it needs to read beyond the cap, 400 can be the first observed failure under this local policy. The important rule is to preserve the actual error evidence rather than rewriting all large-looking failures into one category.

Repair the handler and preserve the previous input contract

The minimal repair is not a new parsing framework. It is a same-decoder EOF gate inserted before the first side effect:

var extra any
if err := dec.Decode(&extra); err != io.EOF {
    if err == nil {
        err = errors.New("extra top-level JSON value")
    }
    reject(err)
    return
}

Keep the existing first decode, byte wrapper, unknown-field rule, root check, and request_id policy unless the endpoint contract is separately changing. That preserves the review surface: the repair closes one request-consumption defect instead of combining it with a schema rewrite or parser migration.

Before enforcement, the API owner should inventory known clients for any intentional multi-value behavior. A client that sends two adjacent JSON values may have been accidentally tolerated by the old handler even though the declared endpoint is single-value. Do not “fix” compatibility by silently turning the endpoint into NDJSON or a JSON stream. If multiple values are genuinely required, that is a different protocol decision and belongs under HOLD until the endpoint contract, media type, tests, and mutation semantics are explicitly redesigned.

For the single-value contract, the recovery sequence is bounded:

1.  Add the second Decode(&extra) on the existing decoder and require exact io.EOF.

2.  Keep MaxBytesReader around the incoming body so suffix proof cannot read without the endpoint’s byte bound.

3.  Run the complete G01–G14 corpus in isolation and retain actual statuses and write counts.

4.  Compare the source digest and runtime metadata with the candidate being reviewed.

5.  Promote only after an independent reviewer verifies that every rejection path remains before the first application mutation.

If the repair produces an unexpected row, preserve it. Do not edit the fixture or expected values to make a chosen implementation pass. Roll back to an already approved safe version, or hold writes while the discrepancy is investigated; do not restore a known first-decode-only admission path as the fallback.

A practical rollout record should therefore contain both code and protocol evidence. Record the endpoint version or commit under review, the fixture digest, the Go runtime and platform, the body limit, the complete case ledger, and the reviewer who reconciled it. If telemetry later reveals clients depending on multiple values, that is evidence for a contract discussion, not permission to move the write earlier. Compatibility pressure can justify a new endpoint or explicit streaming mode; it cannot retroactively turn ambiguous admission into a verified single-value request.

Issue an evidence-backed release decision

A release decision should name the artifact, evidence, owner, and next action. “Tests pass” is not enough because this very fixture is designed to pass while demonstrating that one candidate is unsafe.

Decision

Evidence threshold

Owner-approved next action

ACCEPT

Guarded candidate matches G01–G14; only G01/G02 reach 204 / 1; G11/G12 are 413 / 0; all other rows are 400 / 0; source and environment provenance recorded

API owner authorizes release of that candidate

REPAIR

First-decode-only path, or any candidate that can write before suffix/size admission

Implementer moves the mutation boundary behind same-decoder EOF proof, then resubmits

RERUN

Source digest, runtime/platform, complete case ledger, or write-count evidence is missing or does not identify the reviewed artifact

QA/reviewer reruns the exact candidate and preserves actual output

HOLD

Contract on multiple top-level values is unresolved, side-effect boundary cannot be identified, or observed behavior conflicts with expected contract

API owner resolves policy or architecture before write traffic is approved

For the reproduced fixture on go1.23.2 linux/amd64, the guarded path matches the stated acceptance matrix: G01 and G02 are the only successful requests; G11 and G12 are size rejections; every other case is a zero-write bad request. The first-only candidate requires REPAIR because it writes for G03, G04, G05, G06, G12, and G14. This result establishes the behavior of the displayed cases, not exhaustive coverage of a production endpoint.

Operational ownership should be explicit. The API owner owns the single-value policy and the location of the first allowable mutation. The implementer owns the code change and artifact provenance. An independent reviewer or QA engineer owns the rerun and reconciliation of statuses plus writes. Broader API security controls remain separate controls; this parser gate neither authenticates a caller nor authorizes an operation.

Independence is valuable because the implementer knows the intended fix and can unconsciously read ambiguous output in its favor. The reviewer’s job is mechanical: verify the candidate source, execute or inspect the identified run, reconcile every row against the predeclared matrix, and refuse promotion when provenance or write-count evidence is missing. The API owner then makes the policy decision; QA does not silently redefine whether multi-value bodies are allowed.

Retain diagnostic success without promoting the faulty candidate

PASS_14_CASES means the fixture observed what its assertions expected. Those assertions include the unsafe first-only admissions. The pass result therefore validates the diagnostic contrast, not the deployability of every branch exercised.

Release evidence must stay candidate-specific: source digest, environment, case ID, HTTP status, write count, and the decision that follows from the declared contract. That prevents a green harness from being quoted out of context as approval for code that the harness was built to expose as faulty.

Build the API foundations behind explicit admission contracts

Define the request contract and place the first side effect after the evidence that establishes it. Two calls to Decode serve this single-value endpoint; a streaming protocol would need a different admission contract. Here the sequence is one successful first decode into the required object, exact EOF from the same decoder, the application field check, and only then the mutation boundary, with both decodes reading through MaxBytesReader. Go’s documented next-value decoding, decoder buffering, EOF semantics, and request-body limit support that design; the reproduced run shows the concrete failure modes it prevents.

For engineers building broader API practice, Refonte Learning’s APIs Developer Fundamentals page describes a three-month program at 10–12 hours per week, with topics including REST, GraphQL, authentication and authorization, database integration, API documentation/testing, and error handling/logging. It lists Postman, Swagger, Node.js frameworks, and GraphQL libraries in its FAQ, and states an admissions prerequisite of working toward a bachelor’s or higher-level degree. The page also describes a Training Certificate and Certificate of Internship on successful completion. The program provides broader API foundations; its published page does not specify Go instruction or this encoding/json laboratory.

The operational handoff is to document one admission contract, verify the mutation boundary, retain the evidence, and make the release decision from the guarded candidate. A diagnostic harness printing PASS does not approve every candidate it exercises.