Skip to content

fix(api): raise the body limit on the push routes, and only those - #3

Merged
alice-viola merged 2 commits into
masterfrom
fix/push-body-limit
Sep 7, 2026
Merged

fix(api): raise the body limit on the push routes, and only those#3
alice-viola merged 2 commits into
masterfrom
fix/push-body-limit

Conversation

@albertovincenzi

Copy link
Copy Markdown
Collaborator

Il difetto

axum applica un DefaultBodyLimit di 2 MiB a ogni rotta se nessun layer dice altro, e qui non lo diceva nessuno. Quel default era quindi il vero tetto su tutto ciò che un chiamante può consegnare a questo servizio, ed era invisibile: il rifiuto dice

Failed to buffer the request body: length limit exceeded

che non nomina né il limite né il fatto che è nostro e non del vendor dietro il graph.

La misura

Un chiamante ci ha sbattuto contro per una settimana. Misurato dal suo lato il 04/09/2026, dai due lati del muro:

consegnati   11.408 · 10.387 · 8.976 record
rifiutati    12.000 · 16.096 record

Dividendo, 2 MiB è esattamente dove quei numeri si incrociano: i loro payload stanno fra 130 e 175 byte per record a seconda del vendor.

Peggio del rifiuto è stato il costo. Un rifiuto per dimensione è deterministico, quindi il loro retry budget è stato speso a rispedire byte identici, e poi il lavoro è stato parcheggiato — in silenzio — per 2.683 coordinate.

Il cambio

8 MiB, configurabile con GATE_MAX_PUSH_BODY_BYTES.

Il tetto sul tetto è la memoria, non il gusto: un body limit è un buffer per richiesta e questo servizio gira con un limite di 512 MiB. Quattro volte il valore vecchio tiene un chiamante grosso comodamente dentro, e una raffica di dieci push concorrenti costa comunque meno di un sesto del pod.

L'override da environment è floored al default di axum, così un errore di battitura non può far rifiutare al servizio body che accettava prima che qualcuno toccasse la variabile.

Solo le rotte di push, e per rotta

Applicato sulle quattro rotte di push invece che con un .layer() sul Router intero.

Un push è un batch: una richiesta vale per quanti item il chiamante è riuscito a raggruppare, e il limite onesto è la memoria. Una dichiarazione di graph, un poke al breaker e una lettura di console sono documenti, nessuno si è mai avvicinato a 2 MiB, e alzare il loro tetto non comprerebbe niente mentre lascerebbe consegnare a un pod da 512 MiB un body per richiesta che nessuna dichiarazione ha mai richiesto.

Test

Passano dal Router, non rileggono il knob — ed è il punto: il knob non è mai stato la cosa sbagliata, e un test su knobs().max_push_body sarebbe passato allegramente anche il giorno in cui un chiamante veniva rifiutato.

  • un push da 3 MiB è accettato (era il caso che il default rifiutava);
  • un push oltre il tetto è comunque rifiutato — un tetto è un tetto, altrimenti è memoria che nessuno limita;
  • una rotta documento a 3 MiB è ancora rifiutata, cioè l'alzata resta circoscritta.

Mutation-check: togliere il layer da una rotta di push fa cadere il primo, spostarlo sul Router intero fa cadere il terzo.

Nessun broker serve e nessuno viene raggiunto: l'extractor rifiuta prima che l'handler parta.

cargo test, cargo clippy --all-targets --all-features e cargo fmt --check verdi.

https://claude.ai/code/session_01RJHziF1EPc5fH7Kd7WRdgk

albertovincenzi and others added 2 commits September 4, 2026 18:38
axum applies a 2 MiB `DefaultBodyLimit` to every route unless a layer says
otherwise, and nothing here ever did. That default was therefore the real ceiling
on everything a caller can hand this service, and it was invisible: the refusal
reads `Failed to buffer the request body: length limit exceeded`, which names
neither the limit nor the fact that it belongs to us and not to the vendor behind
the graph.

A caller spent a week failing against it. Measured from their side on 2026-09-04,
from both sides of the wall: pushes of 11,408 / 10,387 / 8,976 records went
through, and pushes of 12,000 and 16,096 did not. Divide and 2 MiB is exactly
where those cross — their payloads run about 130 to 175 bytes a record depending
on the vendor. Worse than the refusal was what it cost them: a size refusal is
deterministic, so their retry budget was spent re-sending identical bytes and the
work was then parked, silently, for 2,683 coordinates.

8 MiB, as GATE_MAX_PUSH_BODY_BYTES. The ceiling on the ceiling is memory rather
than taste: a body limit is a per-request buffer and this service runs with a
512 MiB limit, so four times the old value keeps a large caller comfortably
inside it while a burst of ten concurrent pushes still costs under a sixth of the
pod. The env override is floored at axum's own default so that a typo cannot make
the service refuse bodies it accepted before anybody set the variable.

PUSH ROUTES ONLY, applied per route rather than as one layer on the Router. A
push is a batch — one request stands for as many items as the caller grouped —
and the honest bound on it is memory. A graph declaration, a breaker poke and a
console read are documents, none has ever come near 2 MiB, and raising their
ceiling would buy nothing while letting anybody hand a 512 MiB pod a body per
request.

The tests go through the ROUTER rather than reading the knob back, because the
knob was never the thing that was wrong: a test asserting
`knobs().max_push_body` would have passed just as happily on the day a caller was
being refused. Mutation-checked: dropping the layer from a push route fails the
case that a 3 MiB push is accepted, and moving it to the whole Router fails the
case that a document route still refuses one. No broker is needed and none is
reached — the extractor rejects before the handler runs.

Claude-Session: https://claude.ai/code/session_01RJHziF1EPc5fH7Kd7WRdgk
`env_u32` accepts anything up to `u32::MAX`, so `GATE_MAX_PUSH_BODY_BYTES`
could ask each push to buffer about 4 GiB. The value is floored at axum's
default and now capped as well.

The floor was reasoned about and the ceiling was not, and the ceiling is the
side that matters: the limit is a per-request memory RESERVATION, and the wire
body is only one of four copies alive at once — the buffered bytes, the
`serde_json::Value` they parse into, the copy the envelope is built on and the
body sent to the broker. Nothing in front of the router limits concurrency, so
the multiplier is the number of callers, not a constant.

Claude-Session: https://claude.ai/code/session_012K8u7BEJyd6nDNMCQAgH3z
@alice-viola
alice-viola merged commit afc8f87 into master Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants