Skip to content

Report unparsable SQL and pathological nesting instead of raising - #2

Merged
ethanzhoucool merged 2 commits into
mainfrom
claude/never-raise-on-input
Nov 3, 2026
Merged

ethanzhoucool merged 2 commits into
mainfrom
claude/never-raise-on-input

Conversation

@ethanzhoucool

@ethanzhoucool ethanzhoucool commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

explain()'s docstring says it "Never raises on malformed input", and the README says broken code still explains. Two kinds of input broke that promise. Both came back from /v1/explain, /v1/explain/all-levels, /v1/analyze (and the CLI) as an unhandled exception, which the API serves as a bare 500 Internal Server Error.

  1. SQL whose string or comment never closes, e.g. SELECT 'abc or SELECT a FROM t /* note. This is common for half-typed queries. sqlglot fails in the tokenizer and raises TokenError. That is a sibling of the ParseError the adapter catches, not a subclass, so it escaped.

  2. Deeply nested code. The lowering and analysis passes recurse over the tree, so these exhausted Python's recursion limit:

    • a 1,000-term x = a + a + ... + a (about 4 KB of valid Python),
    • a few thousand nested brackets in JS, Java or C++,
    • 500 nested parens in SQL.

    CPython's own parser reports the same condition as MemoryError ("Parser stack overflowed", bare on 3.11).

Changes

  • adapters/sql.py: catch sqlglot.errors.SqlglotError, the base class of ParseError and TokenError. The result is the existing "Could not parse SQL: ..." error diagnostic.
  • core.py: parse, analyse and narrate now run in a small _static_pass() helper. If that raises RecursionError, explain() runs it again on an empty degraded parse. That parse carries one error diagnostic: "This code is nested too deeply to explain ... Split it up and try again." This is how a parse failure is already reported, so the UI shows it as a toast and the rest of the response keeps its usual shape.
  • adapters/python.py: a MemoryError from ast.parse is turned into that same RecursionError. Input is already capped at 200 KB, so it can only mean the parser's stack overflowed. The prefix-salvage loop also skips prefixes that overflow, instead of crashing.

Code that is deep but reasonable is still explained in full. A 150-term chain gives a full, non-degraded result, and a test covers it.

Testing

  • pytest passes on Python 3.13, 3.12 and 3.11: 163 passed, 3 skipped. ruff check explain_my_code tests is clean. mypy still reports the 26 errors it reported before; none are new (CI runs it with continue-on-error).
  • New tests, all failing before this change (15 failed when I ran them against main's source):
    • test_sql_that_fails_to_tokenize_is_reported_not_raised (3 cases)
    • test_pathologically_deep_nesting_is_reported_not_raised (Python chain, Python parser overflow, JS, Java, C++, SQL)
    • test_hostile_input_gets_a_degraded_answer_not_a_500: /v1/explain, /v1/explain/all-levels and /v1/analyze, each with both inputs, using a TestClient(raise_server_exceptions=False) so a 500 shows up as a status code.
    • test_moderately_deep_code_is_still_explained

Notes

Not covered here: includeIr: true on a deep tree, around 100+ levels such as a 200-term chain, still returns 500. pydantic-core's serializer refuses to nest dict[str, Any] that deep ("Circular reference detected (depth exceeded)"). It's opt-in and the UI never sets it, so I left it out rather than change the response model in this PR.

core.explain is also touched by the language-detection PR (#1), but in a different region (SIGNATURES), so the two should merge cleanly.

Merge note: #6 rewrites _parse_longest_prefix, so whichever of #2 and #6 lands second gets a small conflict there. To resolve it, keep #6's loop and add except (MemoryError, RecursionError): end -= 1 after its SyntaxError branch. I merged all of the open PRs together locally with that resolution, and the combined suite passes (266 passed, 4 skipped on 3.11, 3.12 and 3.13).

https://claude.ai/code/session_01UigC7feaY8NkAy9oay3GDW


Generated by Claude Code

explain() promises never to raise on malformed input, but two kinds of
input escaped it and became bare 500s from every endpoint:

- SQL whose string or comment never ends. sqlglot raises TokenError for
  that, which is a sibling of the ParseError the adapter caught, not a
  subclass. Half-typed SQL like `SELECT 'abc` hits it.
- Deeply nested code. Lowering and analysis recurse over the tree, so a
  1,000-term `a + b + ...` chain or a few thousand nested brackets hit
  the recursion limit, and CPython's own parser reports the same thing
  as MemoryError.

The adapter now catches SqlglotError, and explain() catches
RecursionError around the static pass and returns a degraded result
with an error diagnostic that says why, the same way a parse failure
is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UigC7feaY8NkAy9oay3GDW
@ethanzhoucool
ethanzhoucool marked this pull request as ready for review November 3, 2026 17:08
@ethanzhoucool
ethanzhoucool merged commit 4472fbb into main Nov 3, 2026
6 checks passed
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.

2 participants