fix(ai): safely format circular provider stream errors (#107800)

* fix(ai): terminate Anthropic streams on circular error objects

* test(ai): reject with a plain circular object without Promise.reject

* test(ai): pin Anthropic error-message semantics and drop totality overclaim

* test(ai): assert terminal event type in Anthropic error-message test

* fix(ai): preserve circular provider error details

---------

Co-authored-by: Altay <altay@hey.com>
This commit is contained in:
Harjoth Khara
2026-07-27 06:28:40 -07:00
committed by GitHub
parent 3d03c415d1
commit 92eb942822
4 changed files with 85 additions and 2 deletions
@@ -1750,6 +1750,65 @@ describe("Anthropic provider", () => {
expect(result.errorMessage).toContain("ended before message_stop");
});
it("terminates the stream when the thrown error is a circular structure", async () => {
// Socket/HTTP layers raise self-referential error objects; a bare
// JSON.stringify in stream teardown throws and strands the run (#106568).
const circular: Record<string, unknown> = { code: "ECONNRESET" };
circular.self = circular;
// Transport layers reject with plain objects, not Error instances, which is
// what sends the formatter down the JSON.stringify branch.
const asResponse = vi.fn().mockRejectedValue(circular);
const client = {
messages: {
create: vi.fn(() => ({ asResponse })),
},
};
const stream = streamAnthropic(
makeAnthropicModel({ id: "claude-fable-5", name: "Claude Fable 5" }),
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
{ apiKey: "sk-ant-provider", client: client as never },
);
const eventTypes: string[] = [];
for await (const event of stream) {
eventTypes.push(event.type);
}
const result = await stream.result();
expect(eventTypes).toEqual(["error"]);
expect(result.stopReason).toBe("error");
// Keep salient transport fields while replacing the cycle, so the terminal
// diagnostic remains actionable without stranding the stream.
expect(result.errorMessage).toBeTruthy();
expect(result.errorMessage).toBe('{"code":"ECONNRESET","self":"[Circular]"}');
});
it("keeps the message for Anthropic errors that carry no HTTP body", async () => {
// formatProviderError only substitutes status+body when a body is present, so
// ordinary Error rejections must still surface error.message — retry
// classification in src/llm/utils/retry.ts parses this string.
const asResponse = vi
.fn()
.mockRejectedValue(Object.assign(new Error("Overloaded"), { status: 529 }));
const client = {
messages: {
create: vi.fn(() => ({ asResponse })),
},
};
const stream = streamAnthropic(
makeAnthropicModel({ id: "claude-fable-5", name: "Claude Fable 5" }),
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
{ apiKey: "sk-ant-provider", client: client as never },
);
const eventTypes: string[] = [];
for await (const event of stream) {
eventTypes.push(event.type);
}
const result = await stream.result();
expect(eventTypes).toEqual(["error"]);
expect(result.errorMessage).toBe("Overloaded");
});
it("strips Fable thinking when replay targets Anthropic Vertex", async () => {
let capturedPayload: unknown;
const stream = streamAnthropic(
+6 -1
View File
@@ -43,6 +43,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { headersToRecord } from "../utils/headers.js";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js";
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
import { formatProviderError } from "../utils/provider-error.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import {
splitSystemPromptCacheBoundary,
@@ -826,7 +827,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
output.content = [];
}
output.stopReason = requestOptions?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
// A bare JSON.stringify here dies on the circular error objects HTTP/socket
// layers raise, and the throw escapes this catch so stream.end() never runs
// and the consumer hangs. formatProviderError guards that conversion, matching
// the other provider terminal paths.
output.errorMessage = formatProviderError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
@@ -35,6 +35,13 @@ describe("formatProviderError", () => {
expect(formatProviderError(error)).toBe(body);
});
it("preserves diagnostic fields when serializing a circular error object", () => {
const error: Record<string, unknown> = { code: "ECONNRESET" };
error.self = error;
expect(formatProviderError(error)).toBe('{"code":"ECONNRESET","self":"[Circular]"}');
});
it("does not split surrogate pairs when truncating response bodies", () => {
const body = `${"x".repeat(3999)}😀tail`;
const error = Object.assign(new Error("502 status code (no body)"), { status: 502, body });
+13 -1
View File
@@ -16,8 +16,20 @@ type HttpErrorShape = Error & {
};
function stringify(value: unknown): string {
const seen = new WeakSet<object>();
try {
return JSON.stringify(value) ?? String(value);
return (
JSON.stringify(value, (_key, candidate: unknown) => {
if (typeof candidate !== "object" || candidate === null) {
return candidate;
}
if (seen.has(candidate)) {
return "[Circular]";
}
seen.add(candidate);
return candidate;
}) ?? String(value)
);
} catch {
return String(value);
}