pi-mono/packages/ai/test/error-body.test.ts
robopipi[bot] 4c992154d9
fix(ai): keep the underlying cause in connection error messages (#11783)
When a request fails before the provider answers (connection refused or
reset, DNS, TLS or certificate, proxy), the `openai` and Anthropic SDKs
throw an error with the generic message `"Connection error."`. The
actual reason is in the `cause` chain, usually two levels down: undici's
`TypeError: fetch failed` wraps `connect ECONNREFUSED 127.0.0.1:8080`.
pi built `errorMessage` from `error.message` only, so users, print mode,
and session files saw only "Connection error." and had nothing to go on.

This change adds `withCauseDetail()` in `utils/error-body.ts`:
- It walks the `cause` chain, up to 5 levels, and appends the deepest
message, plus its `code` if the message doesn't already contain it. For
example: `Connection error.: connect ECONNREFUSED 127.0.0.1:8080`.
- `normalizeProviderError` applies it only when the error has no HTTP
status and no body. That covers every adapter that goes through
`formatProviderError`.
- The Anthropic adapter's catch block also uses it, since it read
`error.message` directly.

Errors without a cause come out unchanged. Retry classification still
matches, because the message still contains "Connection error".

**Testing**
- Added tests to `packages/ai/test/error-body.test.ts`:
  - a synthetic nested cause chain;
  - adding the code when the message lacks it;
  - no cause leaves the message unchanged;
- a real `openai` SDK call against a closed local port, which now gives
`Connection error.: connect ECONNREFUSED 127.0.0.1:<port>`.
- Without the source change, the nested-chain test and the real SDK test
fail, because the message is just `Connection error.`.
- The full `packages/ai` test suite passes, and `npm run check` passes.

Fixes #11695

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-11-04 09:47:23 +01:00

293 lines
10 KiB
TypeScript

// Unit tests for the shared provider error-body normalizer.
//
// See issues/provider-error-body-passthrough. These cover one synthesized error
// object per SDK shape (Mistral, openai APIError, @google/genai ApiError, AWS
// Bedrock ServiceException), plus the non-Error fallback, truncation, the empty
// parsed-body edge case, and the formatProviderError compose helper.
import type { AddressInfo } from "node:net";
import { createServer } from "node:net";
import { getHeapStatistics } from "node:v8";
import OpenAI from "openai";
import { describe, expect, it } from "vitest";
import {
formatProviderError,
MAX_PROVIDER_ERROR_BODY_CHARS,
normalizeProviderError,
truncateErrorText,
withCauseDetail,
} from "../src/utils/error-body.ts";
import { collectGarbage } from "./memory-helpers.ts";
describe("normalizeProviderError", () => {
it("extracts status and body from a Mistral-shaped error", () => {
const error = Object.assign(new Error("Mistral request failed"), {
statusCode: 403,
body: '{"error":"blocked by gateway WAF"}',
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("reads the parsed body off an openai APIError when the message is opaque", () => {
// makeMessage(status, error, message) yields "<status> status code (no body)"
// when the parsed body is unparsed, while the body stays on error.error.
const error = Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: { error: "blocked by gateway WAF" },
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("preserves the message when @google/genai already folds the body into it", () => {
const body = { error: { code: 403, message: "Permission denied" } };
const error = Object.assign(new Error(JSON.stringify(body)), {
status: 403,
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.messageCarriesBody).toBe(true);
expect(norm.message).toBe(JSON.stringify(body));
});
it("extracts status and body from a Bedrock-shaped ServiceException", () => {
const error = Object.assign(new Error("UnknownError"), {
name: "UnknownError",
$metadata: { httpStatusCode: 403 },
$response: { statusCode: 403, body: '{"message":"blocked by gateway WAF"}' },
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.body).toBe('{"message":"blocked by gateway WAF"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("ignores a Bedrock response stream instead of serializing its internals", () => {
const error = Object.assign(
new Error("Invocation of model ID anthropic.claude-opus-5 with on-demand throughput isn't supported."),
{
name: "ValidationException",
$metadata: { httpStatusCode: 400 },
$response: {
statusCode: 400,
body: { pipe: () => undefined, _events: { close: [null, null] } },
},
},
);
const norm = normalizeProviderError(error);
expect(norm.status).toBe(400);
expect(norm.body).toBeUndefined();
expect(norm.message).toContain("on-demand throughput isn't supported");
expect(norm.messageCarriesBody).toBe(true);
});
it("ignores a class-instance response body without a pipe method instead of serializing it", () => {
// Not every SDK response wrapper is a node stream: web ReadableStreams
// and SDK-specific wrapper classes have no `pipe`, but serializing them
// still yields internals-noise that would replace the real message.
class SdkHttpResponseBody {
locked = false;
state = { storedError: undefined };
}
const error = Object.assign(new Error("Input is too long for requested model."), {
name: "ValidationException",
$metadata: { httpStatusCode: 400 },
$response: { statusCode: 400, body: new SdkHttpResponseBody() },
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(400);
expect(norm.body).toBeUndefined();
expect(norm.message).toContain("Input is too long");
expect(norm.messageCarriesBody).toBe(true);
});
it("ignores a class-instance `error` field instead of serializing it", () => {
class SdkInnerError {
code = "EPROTO";
internalState = {};
}
const error = Object.assign(new Error("TLS handshake failed"), {
status: 502,
error: new SdkInnerError(),
});
const norm = normalizeProviderError(error);
expect(norm.body).toBeUndefined();
expect(norm.message).toBe("TLS handshake failed");
expect(norm.messageCarriesBody).toBe(true);
});
it("still surfaces a plain parsed JSON body object", () => {
const error = Object.assign(new Error("400 status code (no body)"), {
status: 400,
error: { message: "schema validation failed", field: "tools[0]" },
});
const norm = normalizeProviderError(error);
expect(norm.body).toBe('{"message":"schema validation failed","field":"tools[0]"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("JSON-stringifies a non-Error thrown value", () => {
const norm = normalizeProviderError({ reason: "boom" });
expect(norm.status).toBeUndefined();
expect(norm.body).toBeUndefined();
expect(norm.message).toBe('{"reason":"boom"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("treats an empty parsed body object as no body", () => {
const error = Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: {},
});
const norm = normalizeProviderError(error);
expect(norm.body).toBeUndefined();
expect(norm.messageCarriesBody).toBe(true);
});
it("truncates the body at the cap", () => {
const longBody = "x".repeat(MAX_PROVIDER_ERROR_BODY_CHARS + 50);
const error = Object.assign(new Error("failed"), {
statusCode: 500,
body: longBody,
});
const norm = normalizeProviderError(error);
expect(norm.body).toContain("... [truncated 50 chars]");
expect(norm.body?.length).toBeLessThan(longBody.length);
});
it("sets messageCarriesBody when the message already contains the extracted body", () => {
const error = Object.assign(new Error("500: upstream exploded"), {
statusCode: 500,
body: "upstream exploded",
});
const norm = normalizeProviderError(error);
expect(norm.messageCarriesBody).toBe(true);
});
});
describe("formatProviderError", () => {
it("surfaces status and body without a prefix", () => {
const norm = normalizeProviderError(
Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: { error: "blocked by gateway WAF" },
}),
);
const formatted = formatProviderError(norm);
expect(formatted).toContain("403");
expect(formatted).toContain("blocked by gateway WAF");
expect(formatted).not.toBe("403 status code (no body)");
});
it("applies a provider prefix with status and body", () => {
const norm = normalizeProviderError(
Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: { error: "blocked by gateway WAF" },
}),
);
expect(formatProviderError(norm, "OpenAI API error")).toBe(
'OpenAI API error (403): {"error":"blocked by gateway WAF"}',
);
});
it("preserves the message (with prefix + status) when it already carries the body", () => {
const body = JSON.stringify({ error: { message: "Permission denied" } });
const norm = normalizeProviderError(Object.assign(new Error(body), { status: 403 }));
expect(formatProviderError(norm, "OpenAI API error")).toBe(`OpenAI API error (403): ${body}`);
});
it("returns the bare message for a non-Error value", () => {
const norm = normalizeProviderError({ reason: "boom" });
expect(formatProviderError(norm)).toBe('{"reason":"boom"}');
});
});
// #9265
describe("truncateErrorText", () => {
it("keeps an excerpt that does not hold the whole body alive", async () => {
await collectGarbage();
const before = getHeapStatistics().used_heap_size;
const kept = (() => {
const body = "<html>".repeat(1_000_000);
return truncateErrorText(body, MAX_PROVIDER_ERROR_BODY_CHARS);
})();
await collectGarbage();
expect(kept.startsWith("<html><html>")).toBe(true);
expect(kept.endsWith(`[truncated ${6_000_000 - MAX_PROVIDER_ERROR_BODY_CHARS} chars]`)).toBe(true);
// A slice would keep the 6 MB body alive with the excerpt.
expect(getHeapStatistics().used_heap_size - before).toBeLessThan(1_000_000);
});
});
describe("connection error cause detail", () => {
it("appends the deepest cause of a connection error with no status or body", () => {
const socketError = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:8080"), { code: "ECONNREFUSED" });
const fetchError = new TypeError("fetch failed", { cause: socketError });
const error = new Error("Connection error.", { cause: fetchError });
expect(formatProviderError(normalizeProviderError(error))).toBe(
"Connection error.: connect ECONNREFUSED 127.0.0.1:8080",
);
});
it("adds the error code when the cause message lacks it", () => {
const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" });
const error = new Error("Connection error.", { cause });
expect(withCauseDetail(error)).toBe("Connection error.: other side closed (UND_ERR_SOCKET)");
});
it("leaves errors without a cause unchanged", () => {
expect(withCauseDetail(new Error("Connection error."))).toBe("Connection error.");
});
it("surfaces the reason from a real openai SDK connection failure", async () => {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve) => server.close(() => resolve()));
const client = new OpenAI({ apiKey: "test", baseURL: `http://127.0.0.1:${port}/v1`, maxRetries: 0 });
const error = await client.chat.completions
.create({ model: "m", messages: [{ role: "user", content: "hi" }] })
.catch((e: unknown) => e);
expect(formatProviderError(normalizeProviderError(error))).toBe(
`Connection error.: connect ECONNREFUSED 127.0.0.1:${port}`,
);
});
});