views: make the request body limit configurable, default 32 MiB - #91
Merged
Merged
Conversation
axum 0.8 buffers at most 2 MiB of a request body unless the router says otherwise, and nothing here said otherwise. Computer-use agents send screenshots inline, so a single visually busy frame pushes a request over and the gateway answers 413 before any upstream sees it. The same 4 MB request that the gateway rejected in 5.7 ms was accepted by OpenRouter directly. listen.max_request_bytes is read once when the router is built, like host and port. The contract test's oversized case moves above the new default so it still exercises the rejection.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What breaks
axum 0.8 buffers at most 2 MiB of a request body unless the router sets a different limit, and the gateway never did. Computer-use agents send screenshots inline as base64, so one visually busy frame can push a request past 2 MiB and the gateway answers
413 request_entity_too_large_exceptionbefore any upstream sees it. The limit could not be raised from config.Seen in production on the OSWorld node: 1 hit in 224 calls on a four-task regression, and 2 + 2 on two later runs. The driver recovered in each case, but the call was lost.
Evidence that the gateway is the only bottleneck
The same 4,002,891-byte request (one 1000×1000 image), sent two ways:
The limit, bracketed with a model name that does not exist, so nothing reaches a vendor:
The change
listen.max_request_bytes, default 32 MiB, read once when the router is built, likehostandportapp()applies it withaxum::extract::DefaultBodyLimit::max, the only mechanism axum offers for raising the extractor limitlisten:block ofconfiguration.md32 MiB matches Anthropic's own request ceiling. It stays a bound rather than unlimited, because an unbounded body limit is a memory-exhaustion vector.
Tests
request_body_limit_follows_listen_config, new:max_request_bytes: 1024rejects a 4 KiB body withrequest_entity_too_large_exceptionIt fails for the right reason. With only the
DefaultBodyLimitlayer removed (config field kept), the 3 MiB assertion fails because the old 2 MiB default comes back:error_contract_machine_channelalready asserted that a 3 MiB body is rejected, which encoded the old default as the expected behaviour. Its oversized input moves to 33 MiB, so it still tests the rejection's error envelope and nothing else changes.Gates at the workspace root:
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test: 619 passed, 0 failed.Hot path: about 140–210 ns per request, measured in isolation
The layer does one thing per request (
axum-core0.5.6,default_body_limit.rs:224):http::Extensions::insertboxes the value (Box::new(val), a heap allocation freed at the end of the request) and inserts it under itsTypeId; if the extensions map has not been allocated yet it allocates that too, since the map is a lazyOption<Box<AnyMap>>. The body extractor then looks the value up, which is now a hit instead of a miss.Measured on a minimal axum 0.8.9 router with a single
Byteshandler, so the layer is the only difference between arms. Nanosecond resolution, 200,000 requests per run, 8 runs interleaved with the order swapped:About 70 ns of the worst case is the lazy map allocation, which the gateway's own middleware already pays before this layer runs. The remaining ~140 ns is the boxed value, the insert and the lookup hit.
An earlier revision of this description said "about 1 µs". That was an artefact of the benchmark, not of the change.
crates/server/tests/bench.rsrecords latency withas_micros(), which truncates to whole microseconds, so a real shift of ~0.15 µs that carries p50 across a microsecond boundary shows up as a full 1 µs step. The in-gateway A/B (two rounds, n=8 per arm) moved p50 from 20 to 21 µs in 7 of 8 runs, which is consistent with a sub-microsecond shift near the boundary and not evidence of a 1 µs cost.The cost is unavoidable while the limit goes through
DefaultBodyLimit. The alternative is having every body-extracting handler callaxum::body::to_bytes(body, limit)with an explicit limit, which touches all of them to save ~150 ns against an upstream call measured in seconds.