Summary
spinloop gateway silently caps completion request bodies at 1 MiB. A request at or over that size is truncated mid-JSON, fails to parse, and the caller gets:
HTTP 400 {"error":{"message":"the request is not a JSON body: unexpected end of JSON input","type":"gateway_error"}}
Nothing in the message, the docs, or the flags mentions a size limit, so the failure reads as "your agent sent malformed JSON" when the body was in fact perfectly well-formed. A long-running coding-agent session crosses 1 MiB routinely — a large context plus tool schemas and file contents is comfortably over it — so this presents as an agent that works for a while and then starts failing every turn with an error that points at the wrong thing.
Where
requestModel in internal/gateway/gateway.go:317:
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
io.LimitReader truncates without signalling; the caller cannot tell a 1 MiB body from a truncated larger one. The truncated buffer then fails json.Unmarshal a few lines down, producing the 400 above.
The 1 MiB figure looks inherited from the daemon's control API (internal/daemon/api.go:134), where it is entirely reasonable — control-plane JSON is small and bounded. The gateway is different in kind: its bodies carry whole conversations.
Reproduction
Against the gateway package's own fixtures, one request per size, single user message padded to the stated payload size:
body 524352 bytes (payload 512 KiB) -> HTTP 200
body 1048640 bytes (payload 1 MiB) -> HTTP 400: the request is not a JSON body: unexpected end of JSON input
body 2097216 bytes (payload 2 MiB) -> HTTP 400: the request is not a JSON body: unexpected end of JSON input
No existing test covers the limit, in either direction.
Second-order hazard
requestModel returns that same truncated buffer as the body the proxy forwards (gateway.go:311, then gateway.go:472). Today truncation always breaks the JSON first, so nothing malformed reaches an engine — the parse failure shields it. But the contract the function's own doc comment states ("returns it with the full body, which the proxy must forward unmodified") is not what the code does, and any future change that makes model extraction tolerant of a partial body would start forwarding truncated prompts to engines. Worth closing off in the same change.
Suggested fix
- Replace
io.LimitReader with http.MaxBytesReader, so an over-limit body is a distinguishable error rather than a silent truncation.
- Answer it with
413 and a message naming the limit and how to raise it, rather than a JSON parse error.
- Raise the default well above a realistic agent turn, and make it a flag (
--max-request-bytes, alongside --wake-timeout) so an operator serving long-context models can lift it.
- Never forward a body that was not read in full.
Acceptance
- A request just under the limit routes as it does now.
- A request over the limit gets
413 naming the limit and the flag, not 400 "not a JSON body".
- No truncated body is ever proxied to an engine.
- The flag's default is documented in
docs/commands/gateway.md alongside the other gateway flags, and the limit gets a scenario in the fleet-gateway spec.
Summary
spinloop gatewaysilently caps completion request bodies at 1 MiB. A request at or over that size is truncated mid-JSON, fails to parse, and the caller gets:Nothing in the message, the docs, or the flags mentions a size limit, so the failure reads as "your agent sent malformed JSON" when the body was in fact perfectly well-formed. A long-running coding-agent session crosses 1 MiB routinely — a large context plus tool schemas and file contents is comfortably over it — so this presents as an agent that works for a while and then starts failing every turn with an error that points at the wrong thing.
Where
requestModelininternal/gateway/gateway.go:317:io.LimitReadertruncates without signalling; the caller cannot tell a 1 MiB body from a truncated larger one. The truncated buffer then failsjson.Unmarshala few lines down, producing the 400 above.The 1 MiB figure looks inherited from the daemon's control API (
internal/daemon/api.go:134), where it is entirely reasonable — control-plane JSON is small and bounded. The gateway is different in kind: its bodies carry whole conversations.Reproduction
Against the gateway package's own fixtures, one request per size, single user message padded to the stated payload size:
No existing test covers the limit, in either direction.
Second-order hazard
requestModelreturns that same truncated buffer as the body the proxy forwards (gateway.go:311, thengateway.go:472). Today truncation always breaks the JSON first, so nothing malformed reaches an engine — the parse failure shields it. But the contract the function's own doc comment states ("returns it with the full body, which the proxy must forward unmodified") is not what the code does, and any future change that makes model extraction tolerant of a partial body would start forwarding truncated prompts to engines. Worth closing off in the same change.Suggested fix
io.LimitReaderwithhttp.MaxBytesReader, so an over-limit body is a distinguishable error rather than a silent truncation.413and a message naming the limit and how to raise it, rather than a JSON parse error.--max-request-bytes, alongside--wake-timeout) so an operator serving long-context models can lift it.Acceptance
413naming the limit and the flag, not400 "not a JSON body".docs/commands/gateway.mdalongside the other gateway flags, and the limit gets a scenario in the fleet-gateway spec.