Skip to content

Limit gateway POST body buffering#2251

Open
fallintoplace wants to merge 1 commit into
NVIDIA:mainfrom
fallintoplace:fix-gateway-body-limit
Open

Limit gateway POST body buffering#2251
fallintoplace wants to merge 1 commit into
NVIDIA:mainfrom
fallintoplace:fix-gateway-body-limit

Conversation

@fallintoplace

Copy link
Copy Markdown

Summary

This caps gateway POST body buffering with the existing resources.max_upload_bytes limit.

  • Rejects oversized Content-Length requests before reading the body.
  • Tracks cumulative streamed bytes for chunked/no-length uploads and returns 413 once the limit is exceeded.
  • Keeps valid multipart requests working by replaying accepted bodies through the existing cached-body path.

Root Cause

Gateway mode buffered every POST body into memory before route-level upload validation ran. Large or chunked requests could allocate memory before the configured upload limit had a chance to reject them.

Validation

  • uv run pytest tests/test_gateway_body_cache.py tests/test_service_ingest_router.py
  • git diff --check

@fallintoplace fallintoplace requested review from a team as code owners June 19, 2026 23:30
@fallintoplace fallintoplace requested a review from jdye64 June 19, 2026 23:30
@copy-pr-bot

copy-pr-bot Bot commented Jun 19, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds request-body size enforcement to _GatewayBodyCacheMiddleware so that large POST bodies are rejected before they are fully buffered in memory. The limit comes from the existing resources.max_upload_bytes configuration field.

  • Early rejection via Content-Length: A new _content_length helper reads the declared header value; if it exceeds max_body_bytes the middleware responds 413 without calling receive at all.
  • Streaming rejection: For chunked or no-length requests, cumulative byte counts are tracked during the read loop and a 413 is returned as soon as the limit is crossed.
  • New test file (test_gateway_body_cache.py) covers: unlimited mode, early header rejection, exact-boundary acceptance, chunked oversize rejection, and multipart replay — addressing all paths added in this PR.

Confidence Score: 5/5

Safe to merge. Both code paths (header fast-path and streaming accumulation) are correct and well-tested, and the change is strictly additive — existing behaviour is preserved when the limit is not reached.

The middleware changes are self-contained and the logic is straightforward. The header fast-path correctly avoids reading any body when Content-Length alone exceeds the limit. The streaming path accumulates bytes per-chunk and stops precisely when the running total exceeds the configured limit. Both rejection branches use the same JSONResponse helper, keeping error handling consistent. The new test file covers unlimited mode, exact boundary values for both code paths, chunked transfers, and multipart replay. The wiring in create_app uses the existing validated config field (int, ge=1, default 500 MB), so there is no way to accidentally pass an unconstrained value in production.

No files require special attention.

Important Files Changed

Filename Overview
nemo_retriever/src/nemo_retriever/service/app.py Adds max_body_bytes parameter and enforcement to _GatewayBodyCacheMiddleware via header fast-path and streaming byte-count check; wires config.resources.max_upload_bytes at middleware registration time.
nemo_retriever/tests/test_gateway_body_cache.py New test file covering all six key scenarios: unlimited mode, header-based rejection, exact-limit boundaries for both header and streaming paths, chunked oversize detection, and multipart replay.

Reviews (2): Last reviewed commit: "Limit gateway POST body buffering" | Re-trigger Greptile

Comment on lines +258 to +265
def _content_length(scope: dict) -> int | None:
for name, value in scope.get("headers", []):
if name.lower() == b"content-length":
try:
return int(value)
except ValueError:
return None
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 _content_length accepts non-positive values, silently bypassing the early check

int(value) can return 0 (for Content-Length: 0) or a negative integer (e.g. Content-Length: -1, which is technically invalid HTTP but sent by some misbehaving clients). Neither will satisfy declared_size > limit for any positive limit, so the early fast-path rejection is skipped and the request falls through to the streaming loop instead. The streaming loop still enforces the limit correctly, so there is no security bypass, but adding an explicit non-positive guard makes the method's contract unambiguous.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/src/nemo_retriever/service/app.py
Line: 258-265

Comment:
**`_content_length` accepts non-positive values, silently bypassing the early check**

`int(value)` can return `0` (for `Content-Length: 0`) or a negative integer (e.g. `Content-Length: -1`, which is technically invalid HTTP but sent by some misbehaving clients). Neither will satisfy `declared_size > limit` for any positive limit, so the early fast-path rejection is skipped and the request falls through to the streaming loop instead. The streaming loop still enforces the limit correctly, so there is no security bypass, but adding an explicit non-positive guard makes the method's contract unambiguous.

How can I resolve this? If you propose a fix, please make it concise.

await send({"type": "http.response.body", "body": b"accepted"})


def test_gateway_body_cache_rejects_oversized_content_length_without_reading_body() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 There is no test covering the max_body_bytes=None path (unlimited mode). This is the constructor default and is also used directly in unit tests; a regression that accidentally re-enables the limit when None is passed would go undetected. Consider adding a case that verifies bodies of arbitrary size pass through and are cached when no limit is configured.

Suggested change
def test_gateway_body_cache_rejects_oversized_content_length_without_reading_body() -> None:
def test_gateway_body_cache_no_limit_passes_all_bodies_through() -> None:
downstream = _MultipartParsingApp()
app = _GatewayBodyCacheMiddleware(downstream, max_body_bytes=None)
payload = b"x" * 1_000_000 # 1 MB — would exceed any typical small limit
sent, receive_calls = asyncio.run(
_call_asgi(
app,
headers=[(b"content-length", str(len(payload)).encode())],
messages=[{"type": "http.request", "body": payload, "more_body": False}],
)
)
# Should NOT reject; downstream should receive the full body
assert downstream.called is True
assert downstream.cached_body == payload
def test_gateway_body_cache_rejects_oversized_content_length_without_reading_body() -> None:
Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/tests/test_gateway_body_cache.py
Line: 93

Comment:
There is no test covering the `max_body_bytes=None` path (unlimited mode). This is the constructor default and is also used directly in unit tests; a regression that accidentally re-enables the limit when `None` is passed would go undetected. Consider adding a case that verifies bodies of arbitrary size pass through and are cached when no limit is configured.

```suggestion
def test_gateway_body_cache_no_limit_passes_all_bodies_through() -> None:
    downstream = _MultipartParsingApp()
    app = _GatewayBodyCacheMiddleware(downstream, max_body_bytes=None)

    payload = b"x" * 1_000_000  # 1 MB — would exceed any typical small limit

    sent, receive_calls = asyncio.run(
        _call_asgi(
            app,
            headers=[(b"content-length", str(len(payload)).encode())],
            messages=[{"type": "http.request", "body": payload, "more_body": False}],
        )
    )

    # Should NOT reject; downstream should receive the full body
    assert downstream.called is True
    assert downstream.cached_body == payload

def test_gateway_body_cache_rejects_oversized_content_length_without_reading_body() -> None:
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +93 to +172
def test_gateway_body_cache_rejects_oversized_content_length_without_reading_body() -> None:
downstream = _NeverCalledApp()
app = _GatewayBodyCacheMiddleware(downstream, max_body_bytes=4)

sent, receive_calls = asyncio.run(
_call_asgi(
app,
headers=[(b"content-length", b"5")],
messages=[{"type": "http.request", "body": b"12345", "more_body": False}],
)
)

assert _status(sent) == 413
assert b"exceeds limit" in _body(sent)
assert receive_calls == 0
assert downstream.called is False


def test_gateway_body_cache_rejects_chunked_upload_after_limit_is_exceeded() -> None:
downstream = _NeverCalledApp()
app = _GatewayBodyCacheMiddleware(downstream, max_body_bytes=5)

sent, receive_calls = asyncio.run(
_call_asgi(
app,
headers=[(b"transfer-encoding", b"chunked")],
messages=[
{"type": "http.request", "body": b"abc", "more_body": True},
{"type": "http.request", "body": b"def", "more_body": True},
{"type": "http.request", "body": b"ghi", "more_body": False},
],
)
)

assert _status(sent) == 413
assert b"6 bytes exceeds limit of 5 bytes" in _body(sent)
assert receive_calls == 2
assert downstream.called is False


def test_gateway_body_cache_replays_valid_multipart_upload_under_limit() -> None:
downstream = _MultipartParsingApp()
app = _GatewayBodyCacheMiddleware(downstream, max_body_bytes=1024)

boundary = b"----nemo-retriever-test-boundary"
body = (
b"--"
+ boundary
+ b'\r\nContent-Disposition: form-data; name="metadata"\r\n\r\n{}\r\n'
+ b"--"
+ boundary
+ b'\r\nContent-Disposition: form-data; name="file"; filename="doc.txt"\r\n'
+ b"Content-Type: text/plain\r\n\r\nhello gateway\r\n"
+ b"--"
+ boundary
+ b"--\r\n"
)

sent, receive_calls = asyncio.run(
_call_asgi(
app,
headers=[
(b"content-type", b"multipart/form-data; boundary=" + boundary),
(b"content-length", str(len(body)).encode()),
],
messages=[
{"type": "http.request", "body": body[:40], "more_body": True},
{"type": "http.request", "body": body[40:], "more_body": False},
],
)
)

assert _status(sent) == 202
assert _body(sent) == b"accepted"
assert receive_calls == 2
assert downstream.called is True
assert downstream.cached_body == body
assert downstream.filename == "doc.txt"
assert downstream.metadata == "{}"
assert downstream.file_content == b"hello gateway"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 No test for exact-limit boundary (off-by-one check)

Both checks use strict >, so a body whose size is exactly max_body_bytes should be accepted. There's currently no test that sends a body of exactly limit bytes and verifies a 200-series response. Given that the header-check (declared_size > limit) and the streaming check (body_size > limit) are the two separate code paths, a test with len(body) == limit would confirm both boundaries behave consistently.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/tests/test_gateway_body_cache.py
Line: 93-172

Comment:
**No test for exact-limit boundary (off-by-one check)**

Both checks use strict `>`, so a body whose size is exactly `max_body_bytes` should be accepted. There's currently no test that sends a body of exactly `limit` bytes and verifies a 200-series response. Given that the header-check (`declared_size > limit`) and the streaming check (`body_size > limit`) are the two separate code paths, a test with `len(body) == limit` would confirm both boundaries behave consistently.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@fallintoplace fallintoplace force-pushed the fix-gateway-body-limit branch from fdebf3f to 22aac4c Compare June 19, 2026 23:39
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.

1 participant