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:
parent
c1c185c3b9
commit
06d56e5f03
2 changed files with 52 additions and 11 deletions
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue