diff --git a/packages/agent/src/adapters/error-classification.test.ts b/packages/agent/src/adapters/error-classification.test.ts new file mode 100644 index 0000000000..d15bb4851f --- /dev/null +++ b/packages/agent/src/adapters/error-classification.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { classifyAgentError } from "./error-classification"; + +describe("classifyAgentError", () => { + it.each([ + ["API Error: terminated", "upstream_stream_terminated"], + [ + "API Error: Connection closed mid-response. The response above may be incomplete.", + "upstream_stream_terminated", + ], + [ + "API Error: The socket connection was closed unexpectedly.", + "upstream_stream_terminated", + ], + [ + "The socket connection was closed unexpectedly. For more information, pass `verbose: true`", + "upstream_stream_terminated", + ], + ["socket connection closed", "upstream_stream_terminated"], + ["API Error: Connection error.", "upstream_connection_error"], + ["API Error: Request timed out.", "upstream_timeout"], + ["API Error: 429 rate limited", "upstream_provider_failure"], + ["API Error: 529 overloaded", "upstream_provider_failure"], + ["API Error: 400 invalid request", "agent_error"], + [ + "Connection closed mid-response without the API Error prefix", + "agent_error", + ], + ["some unrelated failure", "agent_error"], + [undefined, "agent_error"], + ] as const)("classifies %j as %s", (message, expected) => { + expect(classifyAgentError(message)).toBe(expected); + }); +}); diff --git a/packages/agent/src/adapters/error-classification.ts b/packages/agent/src/adapters/error-classification.ts index 5adf8d40e2..83a88f78fe 100644 --- a/packages/agent/src/adapters/error-classification.ts +++ b/packages/agent/src/adapters/error-classification.ts @@ -23,6 +23,19 @@ export function classifyAgentError( if (/API Error:\s*terminated\b/i.test(text)) { return "upstream_stream_terminated"; } + // Claude Code surfaces an SSE stream that dies after content started + // (no message_stop) as "Connection closed mid-response". Seen when a + // gateway pod is replaced mid-stream or an intermediary cuts the socket + // during a long silent stretch. + if (/API Error:.*Connection closed mid-response/i.test(text)) { + return "upstream_stream_terminated"; + } + // Transport-level socket deaths reported by fetch implementations + // (Bun/undici wording varies) — same failure mode as above. These are raw + // transport errors, so they don't always carry the "API Error:" prefix. + if (/socket connection (?:was )?closed/i.test(text)) { + return "upstream_stream_terminated"; + } if (/API Error:\s*Connection error\b/i.test(text)) { return "upstream_connection_error"; } diff --git a/packages/shared/src/errors.test.ts b/packages/shared/src/errors.test.ts index 15058eb0da..0501363126 100644 --- a/packages/shared/src/errors.test.ts +++ b/packages/shared/src/errors.test.ts @@ -133,6 +133,9 @@ describe("isTransientUpstreamError", () => { "API Error: 500 internal server error", "API Error: 529 overloaded_error", "Internal error: API Error: request timed out", + "Internal error: API Error: Connection closed mid-response. The response above may be incomplete.", + "The socket connection was closed unexpectedly.", + "socket connection closed", ])("recognises %j", (message) => { expect(isTransientUpstreamError(message)).toBe(true); }); diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index 0287bf6670..a910c95b45 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -94,6 +94,10 @@ const FATAL_SESSION_ERROR_PATTERNS = [ const UPSTREAM_TRANSIENT_ERROR_REGEXES = [ /API Error:\s*terminated\b/i, /API Error:\s*Connection error\b/i, + /API Error:.*Connection closed mid-response/i, + // Raw transport-level socket death — wording varies by fetch + // implementation and doesn't always carry the "API Error:" prefix. + /socket connection (?:was )?closed/i, /API Error:.*\b(?:timed out|timeout)\b/i, /API Error:\s*(?:429|5\d\d)\b/i, ] as const;