#015 — deeply nested JSON raised RecursionError (a RuntimeError, not ValueError) and became an unhandled 500 on two paths: - json-kind content validation now catches RecursionError -> 400. - Added a custom DRF EXCEPTION_HANDLER (artifacts/exception_handlers.py, wired via REST_FRAMEWORK) mapping otherwise-unhandled RecursionError -> 400, covering the parser-level crash on deeply nested request bodies (metadata etc.) too. #016 — wrap_csv_viewer handed Grid.js rows without reconciling width to the header, so over-long rows silently dropped cells. Now normalizes every row to the max width: long rows widen the columns (auto-named "Column N"), short rows pad with "". No cell is dropped. Both verified live; 4 regression tests added. 106 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4.7 KiB
015 — Deeply nested JSON raises RecursionError → unhandled HTTP 500 (two code paths)
Status: Fixed
Severity: Medium — unhandled 500 where a 400 is correct; small (~30 KB)
request crashes the handling worker (minor DoS shape); full traceback
exposed when DEBUG=True.
Round-3 probe of the json kind ("deeply nested / very large" from the
#013 handover).
Description
Two distinct code paths turn deeply-nested JSON into an uncaught
RecursionError and a 500. The root cause is shared: Python's
RecursionError is a subclass of RuntimeError, not ValueError, so
the usual except (TypeError, ValueError) guards around JSON parsing don't
catch it.
Path 1 — json kind content validation
validate_content_shape (artifacts/views.py:987):
if kind == Artifact.JSON:
try:
json.loads(content)
except (TypeError, ValueError) as exc: # <-- misses RecursionError
return Response({"detail": f"content must be valid JSON: {exc}"}, status=400)
Posting a json artifact whose content is "[" * 5000 + "]" * 5000
(valid, just deep) raises RecursionError: maximum recursion depth exceeded while decoding a JSON array from a unicode string, which escapes
the except and becomes a 500.
Reproduced:
POST /api/artifacts kind=json content="[[[...5000...]]]"
-> 500 (RecursionError at views.py:987, via post() at views.py:133)
Path 2 — DRF request-body parser (affects ALL kinds)
The same class of payload in the request body itself (e.g. a deeply
nested metadata object, or nesting anywhere in the JSON body) crashes in
DRF's JSONParser before the view runs, so it isn't json-kind
specific:
POST /api/artifacts kind=markdown metadata={"n":{"n":{...5000...}}}
(body ~30 KB, built as a raw string so it's well-formed)
-> 500 (RecursionError inside DRF JSONParser)
Control: same shape at depth 100 -> 201 (confirms it's depth, not size)
Not a size issue
A 50,000-key flat object (~978 KB) posts fine (201). It's specifically
nesting depth that triggers it, so a byte-size limit wouldn't address
this.
Impact
- Wrong status: an unhandled 500 where malformed/pathological input should be a clean 400.
- Minor DoS shape: a single ~30 KB request reliably errors out the worker handling it. Not a resource-exhaustion amplifier, but a cheap way to generate server-side 500s.
- Info exposure only on a
DEBUG=Trueinstance (like the local dev one this was found on): the full Django traceback page is returned. Prod should haveDEBUG=False, so there this degrades to a generic 500 — the status-code bug remains either way. (Flagging the traceback exposure only as a reminder to confirm prodDEBUGis off, not as the core issue.)
Suggested Fix
- Path 1 (cheap, local): add
RecursionErrorto the caught exceptions atviews.py:988(it's aRuntimeError, so either list it explicitly or catch(TypeError, ValueError, RecursionError)), returning the same 400. - Path 2 (needs a global guard): DRF's default exception handler
doesn't map
RecursionErrorto a 4xx. Options: a customREST_FRAMEWORK["EXCEPTION_HANDLER"]that convertsRecursionError(and other parse-timeRuntimeErrors) to400; and/or a bounded JSON parser / max-nesting-depth check applied to request bodies. A depth cap (e.g. reject bodies nested beyond N levels) is the most robust since it rejects before the recursive descent exhausts the stack. - Consider a single shared "parse JSON safely" helper used by both the content validator and any metadata/tag_colors parsing, so the guard lives in one place.
Resolution
Both paths fixed:
- Path 1 (
json-kind content validation):validate_content_shapenow catchesRecursionErroralongside(TypeError, ValueError), returning the same400 content must be valid JSON: …. - Path 2 (deep nesting anywhere in the request body, crashing DRF's
parser before the view): added a custom DRF
EXCEPTION_HANDLER(artifacts/exception_handlers.py, wired viaREST_FRAMEWORKin settings) that maps otherwise-unhandledRecursionErrorto400 Request payload is nested too deeply to parse.This also covers the same class of error inmetadata/tag_colorsstring parsing. Anything that isn't a RecursionError still falls through to Django's normal 500.
Verified live: deep json content and a ~5000-deep metadata body both
return 400; shallow payloads still 201. Tests added in
tests/api/test_artifacts.py. (A depth-cap parser was considered but the
exception-handler approach is simpler and covers every recursive-descent
site at once; prod DEBUG=False already suppresses the traceback page.)