Merge pull request #3204 from jeremymcs/fix/stream-re-catch-all-json-parse

fix(error-classifier): catch-all V8 JSON.parse pattern replaces STREAM_RE whack-a-mole
This commit is contained in:
Jeremy McSpadden 2026-04-01 14:22:07 -05:00 committed by GitHub
commit 03bb723dfb
3 changed files with 58 additions and 4 deletions

11
.gitignore vendored
View file

@ -2,6 +2,13 @@
# ── Compiled test output ──
dist-test/
# ── Compiled output in src/ (should only contain .ts source) ──
src/**/*.js
src/**/*.js.map
src/**/*.d.ts
src/**/*.d.ts.map
!src/**/*.test.js
# ── GSD project state (development-only, lives in worktree branches) ──
package-lock.json
.claude/
@ -42,6 +49,9 @@ tmp/
packages/*/dist/
packages/*/node_modules/
# ── Scratch/WIP files ──
preflight-script.ts
# ── GSD baseline (auto-generated) ──
dist/
!/pkg/dist/modes/
@ -55,6 +65,7 @@ TODOS.md
.planning/
.audits/
docs/coherence-audit/
.plans/
# ── GSD project state (per-worktree, never committed) ──
.gsd/

View file

@ -48,7 +48,9 @@ const NETWORK_RE = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fet
const SERVER_RE = /internal server error|500|502|503|overloaded|server_error|api_error|service.?unavailable/i;
// ECONNRESET/ECONNREFUSED are in NETWORK_RE (same-model retry first).
const CONNECTION_RE = /terminated|connection.?refused|other side closed|EPIPE|network.?(?:is\s+)?unavailable|stream_exhausted(?:_without_result)?/i;
const STREAM_RE = /Unexpected end of JSON|Unexpected token.*JSON|Expected.*in JSON|Unterminated.*in JSON|SyntaxError.*JSON/i;
// Catch-all for V8 JSON.parse errors: all modern variants end with "in JSON at position \d+".
// This eliminates the need to enumerate every error message variant individually.
const STREAM_RE = /in JSON at position \d+|Unexpected end of JSON|SyntaxError.*JSON/i;
const RESET_DELAY_RE = /reset in (\d+)s/i;
/**
@ -91,9 +93,6 @@ export function classifyError(errorMsg: string, retryAfterMs?: number): ErrorCla
}
// 4. Stream truncation — downstream symptom of connection drop
// Checked before server/connection because JSON parse errors can contain
// substrings like "position 500" (matches SERVER_RE) or "Unterminated"
// (matches CONNECTION_RE's "terminated" pattern).
if (STREAM_RE.test(errorMsg)) {
return { kind: "stream", retryAfterMs: retryAfterMs ?? 15_000 };
}

View file

@ -82,3 +82,47 @@ test("#2572: 'SyntaxError' with JSON context (truncated stream) is transient", (
assert.equal(isTransient(result), true, "'SyntaxError...JSON' should be transient");
assert.equal(result.kind, "stream", "JSON parse errors are stream kind");
});
// --- Catch-all: all V8 JSON.parse variants matched by "in JSON at position" ---
test("V8 JSON.parse: 'No number after minus sign in JSON' is transient (#2882)", () => {
const result = classifyError("No number after minus sign in JSON at position 42");
assert.equal(isTransient(result), true);
assert.equal(result.kind, "stream");
});
test("V8 JSON.parse: 'Expected property value after colon' is transient", () => {
const result = classifyError("Expected ',' or '}' after property value in JSON at position 108");
assert.equal(isTransient(result), true);
assert.equal(result.kind, "stream");
});
test("V8 JSON.parse: 'Bad control character in string literal' is transient", () => {
const result = classifyError("Bad control character in string literal in JSON at position 5");
assert.equal(isTransient(result), true);
assert.equal(result.kind, "stream");
});
test("V8 JSON.parse: 'Bad escaped character' is transient", () => {
const result = classifyError("Bad escaped character in JSON at position 17");
assert.equal(isTransient(result), true);
assert.equal(result.kind, "stream");
});
test("V8 JSON.parse: 'Unexpected number' is transient", () => {
const result = classifyError("Unexpected number in JSON at position 0");
assert.equal(isTransient(result), true);
assert.equal(result.kind, "stream");
});
test("V8 JSON.parse: 'Unexpected string' is transient", () => {
const result = classifyError("Unexpected string in JSON at position 12");
assert.equal(isTransient(result), true);
assert.equal(result.kind, "stream");
});
test("V8 JSON.parse with line/column suffix is transient", () => {
const result = classifyError("Unexpected token x in JSON at position 99 (line 3 column 14)");
assert.equal(isTransient(result), true);
assert.equal(result.kind, "stream");
});