Skip to content

bug: the gateway rejects any completion request over 1 MiB with a misleading 400 #240

Description

@outofcoffee

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

  1. Replace io.LimitReader with http.MaxBytesReader, so an over-limit body is a distinguishable error rather than a silent truncation.
  2. Answer it with 413 and a message naming the limit and how to raise it, rather than a JSON parse error.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions