From 92eb942822016606d37a70da63a4d7fcf9a86657 Mon Sep 17 00:00:00 2001 From: Harjoth Khara Date: Mon, 27 Jul 2026 06:28:40 -0700 Subject: [PATCH] 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 --- packages/ai/src/providers/anthropic.test.ts | 59 ++++++++++++++++++++ packages/ai/src/providers/anthropic.ts | 7 ++- packages/ai/src/utils/provider-error.test.ts | 7 +++ packages/ai/src/utils/provider-error.ts | 14 ++++- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/anthropic.test.ts b/packages/ai/src/providers/anthropic.test.ts index 96dd86c651ec..f9b133c55238 100644 --- a/packages/ai/src/providers/anthropic.test.ts +++ b/packages/ai/src/providers/anthropic.test.ts @@ -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 = { 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( diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ac93089551ee..be5f24a8b761 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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(); } diff --git a/packages/ai/src/utils/provider-error.test.ts b/packages/ai/src/utils/provider-error.test.ts index a491b3b52778..ddfc7d9a4551 100644 --- a/packages/ai/src/utils/provider-error.test.ts +++ b/packages/ai/src/utils/provider-error.test.ts @@ -35,6 +35,13 @@ describe("formatProviderError", () => { expect(formatProviderError(error)).toBe(body); }); + it("preserves diagnostic fields when serializing a circular error object", () => { + const error: Record = { 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 }); diff --git a/packages/ai/src/utils/provider-error.ts b/packages/ai/src/utils/provider-error.ts index b04e11a2e132..ab202d5a1648 100644 --- a/packages/ai/src/utils/provider-error.ts +++ b/packages/ai/src/utils/provider-error.ts @@ -16,8 +16,20 @@ type HttpErrorShape = Error & { }; function stringify(value: unknown): string { + const seen = new WeakSet(); 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); }