Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/agent/src/adapters/error-classification.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions packages/agent/src/adapters/error-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Comment thread
tatoalo marked this conversation as resolved.
if (/API Error:\s*Connection error\b/i.test(text)) {
return "upstream_connection_error";
}
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading