mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user