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>
This commit is contained in:
peter 2026-09-03 10:57:07 +01:00
parent 5c53643349
commit 5f872b4b32
6 changed files with 110 additions and 7 deletions

View file

@ -0,0 +1,24 @@
from rest_framework.response import Response
from rest_framework.views import exception_handler
def daybook_exception_handler(exc, context):
"""DRF exception handler that additionally maps pathological-input crashes
to a clean 400 instead of an unhandled 500.
Deeply nested JSON in the request body raises RecursionError inside DRF's
JSONParser (before any view validation runs). RecursionError subclasses
RuntimeError, not ValueError, so DRF's default handler doesn't map it and
it surfaces as a 500. Anything the default handler already knows about is
left to it; anything still unhandled that isn't a RecursionError is
returned as None so Django produces its normal 500.
"""
response = exception_handler(exc, context)
if response is not None:
return response
if isinstance(exc, RecursionError):
return Response(
{"detail": "Request payload is nested too deeply to parse."},
status=400,
)
return None

View file

@ -866,8 +866,14 @@ def wrap_csv_viewer(content, title, request):
gridjs_js = request.build_absolute_uri("/static/vendor/gridjs/gridjs.umd.js")
gridjs_css = request.build_absolute_uri("/static/vendor/gridjs/mermaid.min.css")
rows = [row for row in csv.reader(io.StringIO(content)) if row]
columns = rows[0] if rows else []
data = rows[1:] if len(rows) > 1 else []
# Normalize ragged rows to a single width so no cell is ever dropped by
# Grid.js (which keys body cells by column). Widen the header for rows
# longer than it (auto-naming the extra columns), and pad short rows with
# empty strings. Issue #016.
width = max((len(row) for row in rows), default=0)
header = rows[0] if rows else []
columns = [header[i] if i < len(header) else f"Column {i + 1}" for i in range(width)]
data = [[row[i] if i < len(row) else "" for i in range(width)] for row in rows[1:]]
table_json = escape_script_body(json.dumps({"columns": columns, "data": data}))
return f"""<!doctype html>
<html>
@ -1036,7 +1042,9 @@ def validate_content_shape(kind, content):
if kind == Artifact.JSON:
try:
json.loads(content)
except (TypeError, ValueError) as exc:
# RecursionError (deeply nested JSON) is a RuntimeError, not a
# ValueError, so it must be caught explicitly or it becomes a 500.
except (TypeError, ValueError, RecursionError) as exc:
return Response({"detail": f"content must be valid JSON: {exc}"}, status=400)
return None