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>
This commit is contained in:
robopipi[bot] 2026-11-04 09:47:23 +01:00 • committed by GitHub
parent 4cf69332c3
commit 4c992154d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 73 additions and 2 deletions

View file

@ -37,6 +37,7 @@ import type {
ToolResultMessage,
} from "../types.ts";
import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
import { withCauseDetail } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts";
@ -1163,7 +1164,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
stream.end();
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
output.errorMessage = error instanceof Error ? withCauseDetail(error) : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}

View file

@ -46,15 +46,42 @@ export function normalizeProviderError(error: unknown): NormalizedProviderError
const status = extractStatus(sdkError);
const body = extractBody(sdkError);
const messageCarriesBody = body === undefined || error.message.includes(body);
const message = status === undefined && body === undefined ? withCauseDetail(error) : error.message;
return {
status,
body,
message: error.message,
message,
messageCarriesBody,
} satisfies NormalizedProviderError;
}
const MAX_CAUSE_DEPTH = 5;
/**
* Connection-level failures carry no status or body, and SDKs replace the
* reason with a generic message (`openai`: `"Connection error."`). The real
* reason sits in the `cause` chain, often two levels deep (undici's
* `"fetch failed"` wraps `connect ECONNREFUSED ...`). Append the deepest
* cause so callers that surface only the message keep the reason.
*/
export function withCauseDetail(error: Error): string {
let deepest: Error | undefined;
let current: unknown = error.cause;
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current instanceof Error; depth++) {
deepest = current;
current = current.cause;
}
if (!deepest) return error.message;
let detail = (deepest.message || deepest.name).trim();
const code = (deepest as Error & { code?: unknown }).code;
if ((typeof code === "string" || typeof code === "number") && !detail.includes(String(code))) {
detail = detail ? `${detail} (${code})` : String(code);
}
if (!detail || error.message.includes(detail)) return error.message;
return error.message ? `${error.message}: ${detail}` : detail;
}
/**
* Probe the HTTP status, first numeric hit wins, in SDK-field order:
* `statusCode` (Mistral) → `status` (`openai`, `@google/genai`) →

View file

@ -5,13 +5,17 @@
// 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";
@ -248,3 +252,42 @@ describe("truncateErrorText", () => {
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}`,
);
});
});