Skip to content

Commit 32723ec

Browse files
Merge pull request #37 from NVIDIA-NeMo/security/viewer-ingest-resource-limits
Viewer Ingest Resource Limits
2 parents f22805b + 5633bb5 commit 32723ec

3 files changed

Lines changed: 89 additions & 20 deletions

File tree

src/nooa/viewer/main.py

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import asyncio
1111
import hmac
1212
import ipaddress
13+
import json
1314
import logging
1415
import os
1516
import time
@@ -34,6 +35,30 @@
3435

3536
log = logging.getLogger(__name__)
3637

38+
_INGEST_MAX_BODY_BYTES = 16 * 1024 * 1024
39+
_INGEST_QUEUE_MAX_ITEMS = 64
40+
41+
42+
async def _read_limited_body(request: Request) -> bytes:
43+
"""Read at most ``_INGEST_MAX_BODY_BYTES`` without buffering more."""
44+
raw_length = request.headers.get("content-length")
45+
if raw_length and raw_length.isdecimal() and int(raw_length) > _INGEST_MAX_BODY_BYTES:
46+
raise HTTPException(status_code=413, detail="ingest body too large")
47+
48+
chunks: list[bytes] = []
49+
body_size = 0
50+
async for chunk in request.stream():
51+
body_size += len(chunk)
52+
if body_size > _INGEST_MAX_BODY_BYTES:
53+
raise HTTPException(status_code=413, detail="ingest body too large")
54+
chunks.append(chunk)
55+
return b"".join(chunks)
56+
57+
58+
async def _read_limited_json(request: Request) -> object:
59+
return json.loads(await _read_limited_body(request))
60+
61+
3762
# ---------------------------------------------------------------------------
3863
# Write queue — decouple HTTP ingest latency from SQLite write latency.
3964
#
@@ -46,8 +71,8 @@
4671
# at a time, the event loop is never blocked, and HTTP latency is near-zero.
4772
# ---------------------------------------------------------------------------
4873

49-
_ingest_queue: asyncio.Queue[bytes] = asyncio.Queue()
50-
_QUEUE_WARN_THRESHOLD = 500 # log a warning if the backlog grows this large
74+
_ingest_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_INGEST_QUEUE_MAX_ITEMS)
75+
_QUEUE_WARN_THRESHOLD = 48
5176

5277
# Single-writer thread pool: exactly one thread owns the write connection.
5378
# Using max_workers=1 ensures serial SQLite writes with no concurrent access.
@@ -216,21 +241,17 @@ async def otlp_ingest(request: Request):
216241
(3-5 MB payloads) don't block the event loop while parsing.
217242
"""
218243
try:
219-
body_bytes = await request.body()
244+
body_bytes = await _read_limited_body(request)
220245
except ClientDisconnect:
221-
# BSP exporter timed out and closed the connection before we read the
222-
# body — log at WARNING (not ERROR) since this is a transient backpressure
223-
# signal, not a bug. The BSP will retry on the next export cycle.
224246
log.warning("[otlp_ingest] Client disconnected before body was read — BSP may retry")
225247
return JSONResponse(status_code=499, content={"error": "client disconnected"})
226248
qsize = _ingest_queue.qsize()
227249
if qsize >= _QUEUE_WARN_THRESHOLD:
228-
log.warning(
229-
"[otlp_ingest] Write queue backlog: %d pending — "
230-
"SQLite may not be keeping up with ingest rate.",
231-
qsize,
232-
)
233-
await _ingest_queue.put(body_bytes)
250+
log.warning("[otlp_ingest] Write queue backlog: %d pending", qsize)
251+
try:
252+
_ingest_queue.put_nowait(body_bytes)
253+
except asyncio.QueueFull:
254+
return JSONResponse(status_code=503, content={"error": "ingest queue is full"})
234255
return JSONResponse(content={"queued": True})
235256

236257

@@ -278,7 +299,8 @@ async def journal_messages_ingest(request: Request):
278299
"""
279300
import sqlite3
280301

281-
body = await request.json()
302+
body = await _read_limited_json(request)
303+
282304
if not isinstance(body, list):
283305
return JSONResponse(
284306
status_code=400,
@@ -315,7 +337,8 @@ async def journal_call_ingest(request: Request):
315337
"""
316338
import sqlite3
317339

318-
body = await request.json()
340+
body = await _read_limited_json(request)
341+
319342
if not isinstance(body, dict) or not body.get("call_id") or not body.get("session_id"):
320343
return JSONResponse(
321344
status_code=400,
@@ -361,7 +384,8 @@ async def journal_blocks_ingest(request: Request):
361384
"""
362385
import sqlite3
363386

364-
body = await request.json()
387+
body = await _read_limited_json(request)
388+
365389
if not isinstance(body, list):
366390
return JSONResponse(
367391
status_code=400,

tests/viewer/test_main.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,7 @@ def test_api_routes_require_configured_bearer_token(client, monkeypatch):
139139
assert write_response.status_code == 401
140140
assert read_response.headers["www-authenticate"] == "Bearer"
141141

142-
authorized = client.get(
143-
"/api/version", headers={"Authorization": "Bearer test-viewer-token"}
144-
)
142+
authorized = client.get("/api/version", headers={"Authorization": "Bearer test-viewer-token"})
145143
assert authorized.status_code == 200
146144

147145

@@ -258,6 +256,17 @@ def test_dedup_idempotent(self, client):
258256
assert r1.status_code == 200
259257
assert r2.status_code == 200
260258

259+
def test_oversized_body_is_rejected_before_json_decode(self, client, monkeypatch):
260+
monkeypatch.setattr(main_module, "_INGEST_MAX_BODY_BYTES", 32)
261+
262+
response = client.post(
263+
"/v1/journal/messages",
264+
content=b"x" * 33,
265+
headers={"Content-Type": "application/json"},
266+
)
267+
268+
assert response.status_code == 413
269+
261270

262271
# ---------------------------------------------------------------------------
263272
# POST /v1/journal/calls

tests/viewer/test_otlp_ingest.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,13 @@ async def http_client(mock_store, tmp_path):
4646
The lifespan is NOT triggered (httpx limitation), so no worker task runs.
4747
Suitable for testing the endpoint handler in isolation.
4848
"""
49-
with patch("nooa.viewer.main.otlp_store", mock_store):
50-
from nooa.viewer.main import app
49+
from nooa.viewer.main import app
5150

51+
fresh_queue: asyncio.Queue = asyncio.Queue(maxsize=256)
52+
with (
53+
patch("nooa.viewer.main.otlp_store", mock_store),
54+
patch("nooa.viewer.main._ingest_queue", fresh_queue),
55+
):
5256
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
5357
yield c
5458

@@ -70,6 +74,38 @@ async def test_does_not_call_ingest_synchronously(self, http_client, mock_store)
7074
# Without a running worker, ingest_batch_write_bytes should NOT have been called yet.
7175
mock_store.ingest_batch_write_bytes.assert_not_called()
7276

77+
async def test_rejects_declared_body_over_limit_before_queueing(self, http_client):
78+
from nooa.viewer import main
79+
80+
with patch.object(main, "_INGEST_MAX_BODY_BYTES", 32):
81+
response = await http_client.post("/v1/traces", content=b"x" * 33)
82+
83+
assert response.status_code == 413
84+
assert main._ingest_queue.empty()
85+
86+
async def test_rejects_chunked_body_over_limit(self, http_client):
87+
from nooa.viewer import main
88+
89+
async def chunks():
90+
yield b"x" * 20
91+
yield b"x" * 20
92+
93+
with patch.object(main, "_INGEST_MAX_BODY_BYTES", 32):
94+
response = await http_client.post("/v1/traces", content=chunks())
95+
96+
assert response.status_code == 413
97+
assert main._ingest_queue.empty()
98+
99+
async def test_full_queue_returns_503(self, http_client):
100+
from nooa.viewer import main
101+
102+
full_queue: asyncio.Queue = asyncio.Queue(maxsize=1)
103+
full_queue.put_nowait(b"existing")
104+
with patch.object(main, "_ingest_queue", full_queue):
105+
response = await http_client.post("/v1/traces", content=b"{}")
106+
107+
assert response.status_code == 503
108+
73109

74110
# ---------------------------------------------------------------------------
75111
# Worker tests — verify the background task drains the queue correctly

0 commit comments

Comments
 (0)