fix(error-classifier): widen STREAM_RE to cover all 7 V8 JSON parse error variants (#2916) (#3243)

Stream-truncation JSON parse errors like "Expected ',' or '}' after
property value in JSON" were falling through to kind: "unknown", causing
permanent auto-mode pause instead of transient 15s backoff.

- Broaden STREAM_RE: replace narrow "Expected double-quoted property name"
  with "Expected.*in JSON" and add "Unterminated.*in JSON" to catch all 7
  V8 JSON parse error message variants
- Move stream check before server/connection checks to prevent false
  matches (e.g. "position 500" matching SERVER_RE, "Unterminated" matching
  CONNECTION_RE's "terminated" pattern)
- Add 4 test cases for the previously uncovered V8 error variants

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:17:25 -04:00 committed by GitHub
parent c1c185c3b9
commit 06d56e5f03
2 changed files with 52 additions and 11 deletions

View file

@ -48,7 +48,7 @@ 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 double-quoted property name|SyntaxError.*JSON/i;
const STREAM_RE = /Unexpected end of JSON|Unexpected token.*JSON|Expected.*in JSON|Unterminated.*in JSON|SyntaxError.*JSON/i;
const RESET_DELAY_RE = /reset in (\d+)s/i;
/**
@ -58,9 +58,9 @@ const RESET_DELAY_RE = /reset in (\d+)s/i;
* 1. Permanent (auth/billing/quota) unless also rate-limited
* 2. Rate limit (429, rate.?limit, too many requests)
* 3. Network (ECONNRESET, ETIMEDOUT, socket hang up, fetch failed, dns)
* 4. Server (500/502/503, overloaded, server_error)
* 5. Connection (terminated, ECONNREFUSED, EPIPE, other side closed)
* 6. Stream truncation (malformed JSON from mid-stream cut)
* 4. Stream truncation (malformed JSON from mid-stream cut)
* 5. Server (500/502/503, overloaded, server_error)
* 6. Connection (terminated, ECONNREFUSED, EPIPE, other side closed)
* 7. Unknown
*/
export function classifyError(errorMsg: string, retryAfterMs?: number): ErrorClass {
@ -90,21 +90,24 @@ export function classifyError(errorMsg: string, retryAfterMs?: number): ErrorCla
return { kind: "network", retryAfterMs: retryAfterMs ?? 3_000 };
}
// 4. Server errors — try fallback model
// 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 };
}
// 5. Server errors — try fallback model
if (SERVER_RE.test(errorMsg)) {
return { kind: "server", retryAfterMs: retryAfterMs ?? 30_000 };
}
// 5. Connection errors — try fallback model
// 6. Connection errors — try fallback model
if (CONNECTION_RE.test(errorMsg)) {
return { kind: "connection", retryAfterMs: retryAfterMs ?? 15_000 };
}
// 6. Stream truncation — downstream symptom of connection drop
if (STREAM_RE.test(errorMsg)) {
return { kind: "stream", retryAfterMs: retryAfterMs ?? 15_000 };
}
// 7. Unknown
return { kind: "unknown" };
}

View file

@ -118,6 +118,44 @@ test("classifyError: rate limit takes precedence over auth keywords", () => {
assert.ok(isTransient(result));
});
// ── STREAM_RE: V8 JSON parse error variants (#2916) ────────────────────────
test("classifyError: 'Expected comma/brace after property value in JSON' is transient stream", () => {
const result = classifyError(
"Expected ',' or '}' after property value in JSON at position 2056 (line 1 column 2057)"
);
assert.equal(result.kind, "stream");
assert.ok(isTransient(result));
assert.ok("retryAfterMs" in result && result.retryAfterMs === 15_000);
});
test("classifyError: 'Expected colon after property name in JSON' is transient stream", () => {
const result = classifyError(
"Expected ':' after property name in JSON at position 500 (line 1 column 501)"
);
assert.equal(result.kind, "stream");
assert.ok(isTransient(result));
assert.ok("retryAfterMs" in result && result.retryAfterMs === 15_000);
});
test("classifyError: 'Expected property name or brace in JSON' is transient stream", () => {
const result = classifyError(
"Expected property name or '}' in JSON at position 42 (line 1 column 43)"
);
assert.equal(result.kind, "stream");
assert.ok(isTransient(result));
assert.ok("retryAfterMs" in result && result.retryAfterMs === 15_000);
});
test("classifyError: 'Unterminated string in JSON' is transient stream", () => {
const result = classifyError(
"Unterminated string in JSON at position 100 (line 1 column 101)"
);
assert.equal(result.kind, "stream");
assert.ok(isTransient(result));
assert.ok("retryAfterMs" in result && result.retryAfterMs === 15_000);
});
// ── isTransientNetworkError ──────────────────────────────────────────────────
test("isTransientNetworkError detects ECONNRESET", () => {