daybook/issues/015-deeply-nested-json-recursionerror-500.md
peter 5f872b4b32 Fix #015 (nested-JSON 500) and #016 (ragged CSV data loss)
#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>
2026-09-03 10:57:07 +01:00

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=True instance (like the local dev one this was found on): the full Django traceback page is returned. Prod should have DEBUG=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 prod DEBUG is off, not as the core issue.)

Suggested Fix

  • Path 1 (cheap, local): add RecursionError to the caught exceptions at views.py:988 (it's a RuntimeError, 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 RecursionError to a 4xx. Options: a custom REST_FRAMEWORK["EXCEPTION_HANDLER"] that converts RecursionError (and other parse-time RuntimeErrors) to 400; 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_shape now catches RecursionError alongside (TypeError, ValueError), returning the same 400 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 via REST_FRAMEWORK in settings) that maps otherwise-unhandled RecursionError to 400 Request payload is nested too deeply to parse. This also covers the same class of error in metadata/tag_colors string 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.)