Skip to content

Add configurable Axum buffered JSON body limits - #407

Open
ChristianPavilonis wants to merge 6 commits into
issue-392-396-production-hosting-specfrom
issue-393-buffered-body-limit-spec
Open

ChristianPavilonis wants to merge 6 commits into
issue-392-396-production-hosting-specfrom
issue-393-buffered-body-limit-spec

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Current review readiness — 2026-11-10

Ready for review at the publication head below. The current-head required test aggregate passed in run42916835491; additional pending runs are not a blanket review prerequisite. Reviewers: aram356, prk-Jr, dhruv8sh.

Review transitions are independent of TS work and the combined integration audit; #462 remains draft. This current status supersedes historical draft-only prerequisites below without claiming unexecuted deployment/manual acceptance complete. All three reviewers are requested. Stack465 is the current grouping; #453 is excluded.

Current review-stack checkpoint

Publication head: 8a2a0b7d7d268c6e2b7269dc49e02c47c5f04b8a. This cleanup preserves the existing feature unit and PR identity. The additional change is the completed generated-native build correction: image-only code now follows the scaffold's existing Fastly-example selection, with no request-policy change. Its focused generator/native proof is retained; fresh per-branch hosted checks are not assumed green.

Review order: #275 → #406 → #407 → #409 → #414 → #422 → #439 → #454. Draft integration checkpoint: #462. New SDK/group/codec/phase development and unrelated TS work are not prerequisites or additions to this PR. Earlier notes below retain their original tested revisions and limits; they are not a whole-tree pass for this new head.

Earlier implementation and proof notes

RC refresh checkpoint, 2026-11-10

Current head: 4856d950bc724eb7d3d28df783d7cf4b23ff90e5.

Current #275 Body/BodyPayload, exact outbound egress owner and immutable initial/cached JSON policy are retained. This does not restore the obsolete carrier enum. Internal reconstruction preserves policy and provenance even after mutable headers/extensions change; raw transfer retains the exact owner.

Focused core/Axum, cancellation/forwarding/reconstruction and exact-owner tests, scoped Clippy and independent correctness inspection pass. The lower CI fixes are merged and both fixture locks now declare the added MIME dependency. No dependency version change belongs to this layer.

Draft pending fresh hosted checks and the combined RC audit. Two unchanged native default-parallel deadline misses remain recorded and unclassified; serial passes and untouched-foundation parallel passes do not clear them. No timing bound was relaxed. No deployment, main merge, security-alert dismissal or review-thread resolution occurred. Historical design/review context follows.


Summary

  • Bound framework-managed Axum JSON buffering to a configurable 2 MiB default, exactly 2,097,152 bytes, with typed HTTP 413 on overflow. Keep taken streams, forwarding, manual collection, and other providers' defaults unchanged.
  • Preserve the original ceiling, JSON ingress classification, and cached provenance through request reconstruction, independent of mutable headers and extensions.
  • Reuse admission, lazy conversion, the existing collector/cache/poison state, and response lifecycle. Add strict startup validation and real HTTP/1 boundary tests.

Stacked on #406, which is based on #275. This implementation was explicitly authorized on the current hosting base at 3ab2209, without waiting for accepted #275 integration. No parent code was merged or rebased. Keep this PR draft until the later prerequisite rebase/re-audit and remaining acceptance work are complete.

Changes

Crate / File Change
Core body.rs, lib.rs Opaque policy/cache carrier, non-exhaustive public Body, exhaustive BodyContent raw handoff, and external API fixtures
Core context, extractors, errors Effective native/caller caps, typed PayloadTooLarge, sticky initial errors, and stateless cached rechecks
Core outbound and response preparation Strip ingress metadata at transport boundaries while preserving logical content and byte allocations
All four adapters' outbound/response consumers Migrate transport branching to exhaustive BodyContent without changing provider policies
Axum run options and development server Validated positive limit, fallible builders, strict decimal environment parsing, and safe OS-string development capture
Axum request/service Carry the selected limit through existing startup and attach immutable policy lazily after admission
Core, direct-service, and native HTTP/1 tests Reconstruction, cancellation/drop-once, deadline tie 408, usable Content-Length/chunked 413, overrides above 8 MiB, unrestricted taken streams, startup failures, and router-only defaults
Guides, changelog, spec and plan Configuration, approved breaking API migration, verification evidence, and remaining gaps

Breaking API: Body adds Managed and becomes #[non_exhaustive]. Downstream exhaustive raw-Body matches must migrate to accessors or Body::into_content and exhaustive BodyContent, or add a raw-Body wildcard.

Closes

Closes #393.

Part of #391. This PR remains draft until accepted-prerequisite integration and the remaining acceptance work below are complete.

Test plan

  • cargo test --workspace --all-targets
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • cargo check --workspace --all-targets --features "fastly cloudflare spin"
  • SDK-target cargo check --all-targets for Fastly, Spin, and Cloudflare, plus core WASM check
  • All 56 native/WASM adapter feature-matrix combinations
  • cargo test -p edgezero-core --all-targets, 724 unit and 2 integration tests
  • cargo test -p edgezero-core --doc, 2 regular and 4 compile-fail tests; 13 existing examples ignored
  • cargo test -p edgezero-adapter-axum --all-targets --all-features, 192 unit, 13 contract, 16 subprocess tests
  • Qualified tests::json_body_limit_wire_content_length_and_chunked executed separately and reported ok
  • Docs install, formatting, lint, build, outbound-contract guard, and internal Markdown/link/shell checks
  • git diff --check
  • Provider runtime suites, not implied by successful WASM compilation
  • Accepted feat: implement portable outbound HTTP and request lifecycle contracts #275 integration/re-audit and affected acceptance reruns
  • Excluded app-demo workspace and manual CLI serve, no entrypoint/generator source changes in this PR

Final Rust commands checked one 25-file source fingerprint before and after execution. The plan records commands, counts, elapsed times, and local log paths. Local /tmp artifacts are not durable CI evidence.

Remaining gaps include dedicated above-8-MiB reconstruction, actual router app-state replacement dispatch, and router-only override wire cases. Media variants were tested at the configured 64-byte boundary rather than repeated for every default-size framing. The router-only test awaits outer cancellation, not an explicit inner blocking-worker join.

Fastly all-feature native tests could not link provider host ABI imports; portable Fastly tests passed 94. The standalone Viceroy version differs from the pinned version, and wasmtime/wasm-bindgen-test-runner were unavailable, so no provider-runtime certification is claimed. One inherited storage subprocess selected-greeting assertion failed transiently during development, then passed individually and in subsequent full suites. It is unresolved, not classified as a reproduced baseline failure. Final native/workspace runs used no exclusions.

Checklist

  • Changes follow CLAUDE.md conventions
  • No Tokio dependency or blanket Send bound added to portable core
  • Route parameters use {id} syntax
  • HTTP types imported from edgezero_core
  • Existing registry/store wiring retained
  • New behavior has focused tests and independent read-only verification
  • Public source breaks documented
  • No secrets or credentials committed

@dhruv8sh dhruv8sh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary

This adds a configurable 2 MiB ceiling on buffered JSON bodies for Axum, with a 413 status through a new EdgeError::PayloadTooLarge. The runtime logic is sound:

  • The size check runs before each chunk is appended, so streamed bodies are never buffered past the cap.
  • Overflow handling is sticky, and the source is dropped exactly once.
  • The JSON classification is fixed at ingress, and Json/ValidatedJson are capped regardless of Content-Type, so the "only capped when JSON" rule can't be bypassed.

I found no runtime bugs. I'm requesting changes for two blocking questions about integration with #275 (the open PR this branch depends on). Both should be settled before this merges.

😃 Praise

  • 😃 Careful overflow handling in the body reader, strict and leak-free config validation, and thorough end-to-end boundary tests (see inline).

Findings

Blocking

  • ❓ The JSON limit's config surface will clash with #275's AxumIngressConfig (crates/edgezero-adapter-axum/src/run_options.rs:21, inline)
  • ❓ The #275 baseline audit is stale, and the docs are still marked provisional (docs/superpowers/plans/2026-10-06-axum-buffered-json-body-limit.md:25, inline). The same provisional status appears at plan :4 and :34, at docs/superpowers/specs/2026-10-06-axum-buffered-json-body-limit-design.md:4, and at docs/superpowers/specs/2026-08-22-inbound-body-design.md:28.

Non-blocking

  • 🤔 Carrying the policy inside public Body forces a breaking API change (crates/edgezero-core/src/body.rs:42, inline)
  • 🤔 Body::with_buffered_json_policy is public but only adapters need it (crates/edgezero-core/src/body.rs:124, inline)
  • 🤔 The JSON classifier reads only the first Content-Type header (crates/edgezero-adapter-axum/src/request.rs:44, inline)
  • ♻️ capture_dev_environment duplicates the key check in from_env (crates/edgezero-adapter-axum/src/dev_server.rs:741, inline)
  • 📝 Status-code changes the guide doesn't spell out (docs/guide/handlers.md:111, inline)
  • 🌱 Reject early on a declared Content-Length (crates/edgezero-core/src/context.rs:713): JSON reads could fail immediately when the declared Content-Length is over the effective cap, instead of reading up to the cap first. Not needed now.
  • ⛏ Machine-specific paths in the new docs (inline on the plan :21 and the design spec :12). The plan also cites /tmp/393-final-*.log files as evidence at :362-373; suggest replacing those with commands plus results, or a CI run link.
  • 📝 Body grows by one word, which is why #[expect(clippy::large_enum_variant)] was added in crates/edgezero-core/src/outbound.rs and crates/edgezero-adapter-fastly/src/outbound.rs. No action needed, and it goes away if the Body refactor suggestion is adopted.

📌 Out of Scope

  • docs/superpowers/specs/2026-10-05-axum-production-startup-lifecycle-design.md:10 contains the same /home/pav/... worktree path. It comes from #406, so it should be fixed there.

Dependency on #275

I think #275 should block the merge and the final review of this PR, but not this review. #275 has moved past the audited 950cb81 and now adds its own Axum ingress config plus a large dev_server.rs rework. That affects where this config should live and will cause rebase conflicts. The core body.rs/context.rs logic this PR relies on hasn't changed there. Before merge: rebase onto the accepted #275, redo the audit, and settle the config-naming question.

CI Status

  • fmt: PASS
  • clippy: PASS
  • tests: PASS (1,861 passed). One edgezero-macros trybuild failure was environmental (build files vanished mid-build) and passed 9/9 on an isolated rerun.
  • No GitHub checks ran on this PR, likely because its base isn't main.

pub(crate) const DEFAULT: Self =
Self(NonZeroUsize::new(0x0020_0000).expect("positive default"));

pub(crate) fn from_config(env: &EnvConfig) -> anyhow::Result<Self> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ The JSON limit's config surface will clash with #275's AxumIngressConfig

Since this plan's audit, #275 (now 596f9f4) added crates/edgezero-adapter-axum/src/ingress_config.rs. It reads ingress limits from EDGEZERO__ADAPTER__INGRESS__* using the same digits-only validation, and it reworks dev_server.rs (+800/−43 lines).

This PR adds a separate JsonBodyLimit, passed through run_options, dev_server and service, under a different name: EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES. Env var names are a public contract, so it's worth settling now.

Question: should this live in AxumIngressConfig, for example as EDGEZERO__ADAPTER__INGRESS__MAX_JSON_BODY_BYTES reusing its setting() validator? If it stays separate, please document why the naming differs. The same pattern is at line 304.

| HEAD / parent hosting integration | `3ab22096fd117f7506cf437b5ba128e0789e059c` |
| Parent branch | `issue-392-396-production-hosting-spec` |
| Inherited #275 revision | `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a` |
| GitHub #275 on 2026-10-06 | Open, head `950cb81d406d0dc269baf94314547329a4c1cef0`; not treated as accepted or integrated |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ The #275 baseline audit is stale, and the docs are still marked provisional

This says #275's head is 950cb81 and that body.rs, http.rs, context.rs and request.rs are unchanged against it. #275 is now 11 commits ahead (596f9f4). In the files this PR edits, the new commits only add lint expectations and switch tests to ConfigValue (request.rs, service.rs). But they also add ingress_config.rs and the large dev_server.rs rework, so the integration story here no longer holds.

"Deferred gate 1" (:34) is still unticked, and the provisional status is still at :4, at the design spec :4, and at the inbound-body spec addendum :28.

Fix: re-pin to the current #275 head, redo the audit after rebasing, and update or remove the provisional status lines.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REVIEWER: should-fix — residual acceptance evidence, not another buffering implementation

Source-only follow-up at 8a2a0b7d7d268c6e2b7269dc49e02c47c5f04b8a, kept in this existing audit thread to avoid duplicating review findings.

PR #275 already supplies lazy conversion, the bounded pre-append collector, typed 413, cache/poison handling and ingress/egress ownership. The remaining #393 behavior here is the configurable Axum ceiling. Please retain that shared path rather than reintroducing eager conversion or a second collector.

Two remaining acceptance requests:

  1. Interrupted/erroring managed-JSON reads through the actual Axum service. The added managed_json_overflow_drops_source_once_stops_polling_and_keeps_error_egress (service.rs:967) verifies overflow. The source-error and partial-cancellation cases cited at plan line 400 are core-context tests. Please add or identify an adapter-service test that starts a managed JSON drain, then injects a source error or cancels the pending service/drain, and checks exactly-once source release, no further polling, and the existing error/egress mapping where a response is produced. For a cancelled operation, do not require a response that the caller has abandoned. The issue's interrupted-read acceptance should be tied to an executable service-path proof, not inferred solely from the shared collector.

  2. Complete the documented override/reconstruction matrix. production_hosting.rs:435-443 proves an ordinary 9 MiB override, but not reconstruction above the portable 8 MiB fallback. The router-only wire test at dev_server.rs:1918 uses the default; json_limit_development_capture_and_explicit_router_builder checks configuration without exercising an overridden router-only listener. Please exercise initial and cached reconstruction with a ceiling above 8 MiB (preserving the ceiling/provenance), and the router-only configured exact-cap/+1 wire boundary through both constructors. Plan line 406 already records these as remaining gaps; attach current-head test names/results or explicitly resolve their acceptance status.

I checked all 23 existing inline threads and their replies before posting. The obsolete Body::Managed/BodyContent and 400-status documentation issue is already covered by the existing documentation thread; no duplicate finding is needed.

These are missing-evidence requests from source inspection, not newly reproduced runtime defects. I did not rerun the suite for this follow-up and am not claiming approval or closing the existing findings.

Comment thread crates/edgezero-core/src/body.rs Outdated
/// }
/// }
/// ```
#[non_exhaustive]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Carrying the policy inside public Body forces a breaking API change

Downstream code that matches every Body variant now fails to compile (CHANGELOG.md:13 admits this), and every adapter's outbound and response code had to add into_content() normalization. The stated reason is that the policy survives extensions_mut().clear(). But trusted code can already strip it with into_content(), so the tamper resistance gained is small.

Alternative: keep it in a core-private place, such as a field on RequestContext filled from a crate-private request extension at construction. Body would stay exhaustive, and the extra large_enum_variant expectations in the outbound modules would go away. This is non-blocking if the spec owner accepts the trade-off.

Comment thread crates/edgezero-core/src/body.rs Outdated
/// Original policy/provenance win on reattachment. Never polls or copies content.
#[must_use]
#[inline]
pub fn with_buffered_json_policy(self, ceiling: NonZeroUsize, ingress_is_json: bool) -> Self {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Public, but only adapters need it

Applications can use this to attach arbitrary ceilings, and it becomes a stable API commitment. Consider #[doc(hidden)] or moving it to an adapter-facing module.

outbound_transport: Option<Arc<reqwest::Client>>,
json_body_limit: JsonBodyLimit,
) -> Result<CoreRequest, String> {
let ingress_is_json = parts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 The JSON classifier reads only the first Content-Type header

A request with text/plain followed by application/json, or with application/json; broken, is classified as non-JSON and skips the generic-read ceiling. Impact is low, since JSON extraction is capped regardless and generic reads still use the caller's cap. A version that only tightens behavior:

let ingress_is_json = parts.headers.get_all(CONTENT_TYPE).iter()
    .filter_map(|v| v.to_str().ok())
    .any(|raw| is_json_media_type(raw)
        || raw.trim_start().to_ascii_lowercase().starts_with("application/json"));

The same applies at line 90.


| Item | Inspected state |
| --- | --- |
| Worktree | `/home/pav/worktrees/issue-393-buffered-body-limit-spec` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ Machine-specific path: /home/pav/worktrees/... shouldn't be in committed docs; suggest dropping the worktree lines. The same goes for the /tmp/393-final-*.log evidence at :362-373; replace those with commands plus results, or a CI run link.


Stacked base: `issue-392-396-production-hosting-spec` at `3ab22096fd117f7506cf437b5ba128e0789e059c`. This includes the initial #392/#396 implementation and the #275 integration revision recorded in its [startup/lifecycle spec](2026-10-05-axum-production-startup-lifecycle-design.md). It is not the latest or accepted #275 head.

Worktree: `/home/pav/worktrees/issue-393-buffered-body-limit-spec`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ Machine-specific path: please drop the /home/pav/... worktree reference.

Comment thread crates/edgezero-core/src/context.rs Outdated
.len()
.checked_add(chunk.len())
.ok_or_else(|| policy.error("request body size accounting overflow"))?;
if next_len > policy.cap {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😃 Careful overflow handling

The size check runs before each append, and the 413/400 choice comes from the policy rather than from matching message text (covered by managed_source_bad_request_is_not_translated_by_message). A deadline wins over an overflow on a tie, and the source is dropped exactly once and never polled again.

pub(crate) const DEFAULT: Self =
Self(NonZeroUsize::new(0x0020_0000).expect("positive default"));

pub(crate) fn from_config(env: &EnvConfig) -> anyhow::Result<Self> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😃 Strict, leak-free config validation: signs, whitespace, suffixes and fractions are rejected, nothing from the input value leaks into diagnostics, and the end-to-end tests confirm invalid values fail before any app hook runs in both dev and production.

log
}

fn exact_json(bytes: usize) -> Vec<u8> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😃 Thorough end-to-end tests: over the real network, they hit the exact boundary (at the limit and one byte over) with both fixed-length and chunked uploads. They cover the standalone and embedded hosting paths and several media types, and they verify that taken streams stay uncapped.

@ChristianPavilonis
ChristianPavilonis force-pushed the issue-393-buffered-body-limit-spec branch from 3308041 to e867c12 Compare November 10, 2026 19:40

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary

This adds a configurable Axum ceiling, 2 MiB by default, for framework-managed JSON buffering. The limit is carried as a private policy on Body and re-applied through into_request. The core mechanism and config validation are careful, but four things need to change:

  • The new dev-server boundary test is flaky.
  • A cached body re-entering pre-dispatch skips the InputGuard.
  • The docs and the PR description describe an API that this PR does not ship.
  • The CHANGELOG doesn't flag the breaking changes that do ship.

Findings

Blocking

  • 🔧 New boundary test is flaky (dev_server.rs:1965): the 413 races a connection reset. It failed 6/30 runs in isolation and 3/20 in the full suite.
  • 🔧 Cached re-entry bypasses InputGuard (context.rs:781): fallback outcomes differ from base.
  • 🔧 Docs and PR description describe an API that isn't shipped, and the wrong overflow status (inbound-body-design.md:22). The other locations:
    • Spec 2026-10-06-axum-buffered-json-body-limit-design.md:
      • L49, L58, L166, L294, L301–303 and L333–334 state the non-exhaustive Body / BodyContent / adapter-migration design as fact. The note at L18–25 says that design was replaced.
      • L80, L81, L144 and L322 claim overflow returns 400.
      • L256 says there is no request-body-too-large variant.
    • Plan 2026-10-06-axum-buffered-json-body-limit.md has no supersession note:
      • L52, L67–111, L134–153 and L356 specify the Managed/BodyContent design.
      • L114 marks all four adapters' body consumers as aligned.
      • L149, L153, L175, L191 and L193 claim overflow returns 400.
      • L398 cites managed_content_accessors_and_manual_collection_ignore_ingress_ceiling, Body/BodyContent rustdoc fixtures and outbound/response/framing regressions. None of them exist.
      • The L362–385 evidence was recorded on 3ab2209 with "25 changed inputs", not on this 22-file head.
    • PR description:
      • These parts describe changes that are not in the diff: the Changes-table rows for body.rs/lib.rs, core outbound/response preparation and "All four adapters", plus the Breaking API paragraph. The description itself also says the obsolete carrier enum is not restored.
      • The status lines contradict each other: "Ready for review" vs "Keep this PR draft"; "No parent code was merged" vs the merge commits on the branch; "Closes #393" vs spec L387 ("does not close #393").
      • The checked "Public source breaks documented" item refers to a break that didn't happen, while the real breaks are undocumented.
  • 🔧 CHANGELOG misses the real breaking changes, and the rustdoc is stale (CHANGELOG.md:7).

Non-blocking

  • 🤔 with_buffered_json_policy is public, unvalidated, and only the first call takes effect (body.rs:76).
  • 🤔 Rewrapping the body falls back to the looser 8 MiB default (context.rs:355).
  • 🤔 JSON classification misses valid media types (request.rs:156).
  • ♻️ The policy field grows every Body from 40 to 64 bytes (body.rs:27).
  • ⛏ Blanket arbitrary_source_item_ordering expects (body.rs:67, context.rs:338, context.rs:1184).
  • ⛏ Hex literal for 2 MiB (run_options.rs:20).
  • 🌱 No drain before close after an early 413. When the declared Content-Length exceeds the ceiling, the server answers and closes with the body unread, so ordinary clients (reqwest here) often get ECONNRESET instead of the 413. The flaky test above shows it. The docs disclaim client receipt, which is fine for this PR. A later change could read and discard a bounded amount (a few KiB, or a short timeout) before closing, so clients actually see the 413.
  • 🌱 into_core_request has no way to override the limit (request.rs:54).

Earlier review threads

None of the 12 threads from the 2026-10-16 review has a reply. These still apply at this head; please answer them on their threads:

  • ❓ JSON limit env-var naming vs AxumIngressConfig (run_options.rs:22)
  • ❓ Stale #275 baseline audit and provisional status (plan:25)
  • 🤔 Only the first Content-Type header is classified (request.rs:65)
  • ♻️ Duplicate key check in the dev environment capture (dev_server.rs:917). It now shares config_path, but still matches the JSON path with its own literal instead of fixed_setting.
  • ⛏ Machine-specific /home/pav/... paths (spec:12, plan:21)

CI Status

  • fmt: PASS

  • clippy: PASS

  • tests: FAIL on the first run with two failures:

    • dev_server::tests::router_only_default_json_boundary_for_both_constructors
    • the pre-existing connection::tests::pending_chunked_forward_preserves_original_deadlines_over_tcp, which has a 5 ms tolerance and failed 1/20 runs on this head vs 0/20 on base

    PASS on rerun.

  • cargo check --workspace --all-targets --features "fastly cloudflare spin": PASS

  • cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin: PASS

.body(body)
.send()
.await
.expect("prebound listener request");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 This boundary test is flaky: the 413 case races a connection reset

The over-limit request declares Content-Length: 2097153. The server rejects it from the declared length without reading the body, sends 413, and closes the connection with the body unread. reqwest is still writing, gets ECONNRESET (hyper::Error(BodyWrite, ConnectionReset)), and this expect panics.

On macOS it failed 6 of 30 runs in isolation and 3 of 20 runs inside the full edgezero-adapter-axum --lib suite. It also broke my first cargo test --workspace --all-targets.

The new axum.md section already says rejection "does not promise client receipt". The upload helper in tests/production_hosting.rs handles this case: it reads the response independently of the writer and tolerates a reset only when the status is 413.

Fix: send the over-limit case over a raw socket the same way, for example through tokio::task::spawn_blocking(move || post_raw(addr, body)):

fn post_raw(addr: SocketAddr, body: Vec<u8>) -> String {
    use std::io::{Read as _, Write as _};
    let mut socket = std::net::TcpStream::connect(addr).expect("connect");
    let mut writer = socket.try_clone().expect("writer");
    let head = format!(
        "POST /json HTTP/1.1\r\nHost: test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        body.len()
    );
    let sending = std::thread::spawn(move || {
        writer.write_all(head.as_bytes())?;
        writer.write_all(&body)
    });
    let mut response = String::new();
    let read = socket.read_to_string(&mut response);
    let sent = sending.join().expect("writer thread");
    if read.is_err() || sent.is_err() {
        assert!(response.starts_with("HTTP/1.1 413"), "{response}");
    }
    response
}

let (body_policy, transfer) = raw_body.into_transfer();
let body = match transfer {
BodyTransfer::Initial(body) => BodyState::Initial(guard_input(body, Rc::clone(&guard))),
BodyTransfer::Cached(bytes) => BodyState::Cached(bytes),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 A cached body re-entering pre-dispatch skips the InputGuard

On base, a reconstructed buffered body entering new_pre_dispatch became BodyState::Initial(guard_input(..)); a Once body passes straight through guard_input. The first buffered read then ran:

  • guard.check(), which covers the deadline and the declared length vs the fallback max;
  • prepare_protected_body → guard.observe(len) → complete = true.

With a policy attached, this arm now stores BodyState::Cached directly. The Cached arm of buffered_bytes, and therefore transform_pre_dispatch_body, only runs check_cached_body, and complete is never set.

I probed the same flow with an unmanaged body and a managed one, under ReadBodyBeforeFallback admission:

Scenario Base / unmanaged Managed
10 cached bytes, fallback max 4, hook body_bytes(1000) 413, fixed outcome Exceeded Ok(10), no fixed outcome
hook reads before the deadline, deadline passes, fallback discard Complete TimedOut
transform_body expands 4 → 16 bytes, fallback max 8, then discard Complete Exceeded (the output is counted as raw input)

This is only reachable when app code re-dispatches a reconstructed request through begin_ingress; Axum ingress always starts Initial. But managed_routed_reconstruction_restores_initial_and_cache_after_head_mutation exercises exactly this path, so it is a supported flow.

Fix: keep the cached state, but account it against the guard once, in the Cached arm of buffered_bytes:

BodyState::Cached(bytes) => {
    if let Some(guard) = &self.input_guard
        && !guard.complete.get()
    {
        if let Err(error) = guard.check().and_then(|()| guard.observe(bytes.len())) {
            let stored = StoredError::capture(&error);
            let returned = stored.to_edge_error();
            *state = BodyState::Poisoned(stored);
            return Err(returned);
        }
        guard.complete.set(true);
    }
    let result = check_cached_body(&bytes, max);
    *state = BodyState::Cached(bytes);
    return result;
}

Please add a regression test that covers the three rows above.

it for captured JSON/+json ingress. Explicit caller caps may tighten it, and ordinary Form keeps
its 1 MiB cap. Taken streams, fallback discard, forwarding, and manual collection remain excluded.

Public `Body` is non-exhaustive with an opaque, nonrecursive policy carrier. Private initial/cache

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 This addendum describes an API that isn't shipped, and the wrong overflow status

The addendum says public Body "is non-exhaustive" and that "Exhaustive BodyContent exposes raw buffered/streamed ownership to transports". At this head:

  • Body is still a struct with a private ingress_buffer field (body.rs:25).
  • Nothing under crates/ has #[non_exhaustive] on Body, a Managed variant, BodyContent or Body::into_content.
  • The Fastly, Cloudflare and Spin adapters are untouched.

Line 16 says portable overflow returns 400. Base already returns a typed 413: see drain_body in context.rs, Body::into_bytes_bounded, and base's json_within rustdoc ("Returns 413 on overflow").

The same outdated design runs through the spec, the plan and the PR description; the line list is in the review body.

Fix: rewrite this addendum to describe what actually shipped:

  • a private ingress policy/provenance field on the existing Body struct;
  • into_parts strips the policy;
  • the typed 413 is unchanged from base.

Comment thread CHANGELOG.md

### Axum buffered JSON limits and body API

Axum now defaults framework-managed JSON buffering to 2 MiB, exactly 2,097,152 bytes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Breaking changes aren't flagged, and "representation unchanged" is inaccurate

For existing Axum apps, this entry changes behavior that the CHANGELOG normally marks with - **Breaking:** (see "Policy-Aware Axum Servers"):

  • The Axum Json<T> / ValidatedJson<T> default drops from 8 MiB to 2 MiB.

  • Explicit caps above the ceiling are now silently lowered to it and return 413:

    • always: json_within(N) and ValidatedJsonWithin<T, N>;
    • on application/json / +json requests: body_bytes(N), form_within(N), the Form family, and pre-dispatch body_bytes / transform_body(max_input, ..).

    "Explicit application caps may tighten the ceiling" hides that clamp.

  • New public method: Body::with_buffered_json_policy.

  • Body's Debug output gains an ingress_buffer field, and Body grows from 40 to 64 bytes, so "representation remain unchanged" doesn't hold.

Please also update the rustdoc that now describes the wrong behavior:

  • DEFAULT_INBOUND_JSON_BYTES ("accepted by JSON extractors (8 MiB)", context.rs:32)
  • body_bytes ("under the caller's byte cap", context.rs:343)
  • json_within ("Buffers at most max bytes", context.rs:654)

All three should mention the native ceiling.

/// Reattaching a policy preserves the original ceiling and classification.
#[must_use]
#[inline]
pub fn with_buffered_json_policy(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 A public, unvalidated setter where only the first call takes effect

This method is pub only so that the Axum crate can call it, but any application can call it too:

  • with_buffered_json_policy(NonZeroUsize::MAX, ..) skips JsonBodyLimit's <= isize::MAX check and makes plain Json effectively unbounded. A probe got json_body_default() == 18446744073709551615. On base, ordinary Json was always capped at 8 MiB.
  • On a body that already has a policy, the call is a silent no-op. A probe that tried to tighten 4 → 1 still got 4.
  • It works on bodies from every adapter, not only Axum.

The earlier thread on this method is no longer attached to a current line and was not answered.

Suggestion:

  • mark it #[doc(hidden)], as response_egress_framing.rs does;
  • reject ceilings above isize::MAX;
  • either make a repeated call an error, or name the method so first-call-wins is visible.

Some(origin)
}

fn is_json_media_type(raw: &str) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 JSON classification misses valid JSON media types

mime 0.3 rejects some values that RFC 9110 allows, so they are classified as non-JSON. Generic reads then skip the ceiling: body_bytes, form_within, the Form family, and pre-dispatch body_bytes/transform_body. I verified these with mime 0.3.17 and this exact function:

Content-Type classified as JSON
application/json ;charset=utf-8 (RFC 9110 §5.6.6 allows whitespace before ;) false
application/json; charset false
application/json, false
application/json+xml true (a false positive, but harmless)

This adds to the open thread at line 65, which notes that only the first Content-Type value is read. The JSON extractors are unaffected, because they always apply the cap.

Suggestion: classify on the trimmed media type before ;, across every Content-Type value, and fail closed. That also removes the need for the new mime workspace dependency:

fn is_json_media_type(raw: &str) -> bool {
    let essence = raw.split_once(';').map_or(raw, |(essence, _)| essence).trim();
    essence.split_once('/').is_some_and(|(kind, subtype)| {
        kind.eq_ignore_ascii_case("application")
            && (subtype.eq_ignore_ascii_case("json")
                || subtype
                    .rsplit_once('+')
                    .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("json")))
    })
}

The caller would use parts.headers.get_all(CONTENT_TYPE).iter().filter_map(|value| value.to_str().ok()).any(is_json_media_type).

With this, application/json; broken would be classified as JSON. Update that test case; failing closed is the safer direction.

/// A buffered or locally streamed HTTP body with optional response-egress ownership.
pub struct Body {
egress_owner: Option<Box<OutboundScopeOwner>>,
ingress_buffer: Option<IngressBuffer>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Request-only metadata grows every Body by 60%

Measured with size_of on 64-bit:

Type Base This PR
Body 40 64
Response 152 176
OutboundCollectedSlot 200 224

Response and outbound bodies carry the field too, because attach_egress_owner/bind_outbound_scope keep it. This growth is what required the new clippy::large_enum_variant expect on the public OutboundCollectedSlot in outbound/batch.rs.

Option<IngressBuffer> takes 24 bytes because the policy is wrapped in a second enum.

Suggestion: flatten it:

#[derive(Clone, Copy, Debug)]
struct IngressPolicy {
    cached: bool,
    ceiling: NonZeroUsize,
    ingress_is_json: bool,
}
// field: ingress_policy: Option<IngressPolicy>  // 16 bytes via the NonZeroUsize niche

Alternatively, use Option<Box<..>> (8 bytes), since it is set once per request. Flattening also removes one of the three parallel Cached/Initial enums (IngressBuffer, BodyTransfer, BodyState).

source: Option<Revocable<BodyStream>>,
}

#[expect(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ Blanket ordering suppression on whole impl blocks

arbitrary_source_item_ordering is deny-level in this workspace. These #[expect]s cover the whole impl Body here, impl RequestContext (context.rs:338) and the test module (context.rs:1184). They exist only because the new functions were inserted out of order, and they turn the lint off for every future edit to the two largest impls.

Suggestion:

  • In impl Body: put from_transfer after from_stream, into_transfer after into_stream, and with_buffered_json_policy last.
  • In RequestContext: put buffered_bytes after body_kind, and json_body_default before json_within.
  • Then drop the expects.


impl JsonBodyLimit {
pub(crate) const DEFAULT: Self =
Self(NonZeroUsize::new(0x0020_0000).expect("positive default"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ 0x0020_0000 reads less clearly than 2 * 1024 * 1024, which is how DEFAULT_INBOUND_JSON_BYTES / DEFAULT_INBOUND_FORM_BYTES are written (context.rs:33-35). The same applies to the 0x0020_0000/0x0020_0001 literals in the tests here, in dev_server.rs and in production_hosting.rs.

axum_body,
None,
HttpClient::with_client(client),
JsonBodyLimit::DEFAULT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 The public converter has no way to choose the limit

into_core_request attaches JsonBodyLimit::DEFAULT unconditionally. Because with_buffered_json_policy keeps only the first policy, a caller that embeds EdgeZero in its own Axum stack can't raise the limit without stripping the body. Stripping it then falls back to 8 MiB (see the comment on context.rs:355).

Consider adding an into_core_request_with_json_limit(request, limit_bytes) variant later.

This branch has not been deployed

No deployments
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.

Axum: enforce configurable buffered request-body limits

3 participants