From 4c992154d91a85f837b73374a86b8e73eefd0745 Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:47:23 +0100 Subject: [PATCH] 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:`. - 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 --- packages/ai/src/api/anthropic-messages.ts | 3 +- packages/ai/src/utils/error-body.ts | 29 ++++++++++++++- packages/ai/test/error-body.test.ts | 43 +++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index ba25b59a3..a19a5d215 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -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(); } diff --git a/packages/ai/src/utils/error-body.ts b/packages/ai/src/utils/error-body.ts index d35f51a0e..f94a029de 100644 --- a/packages/ai/src/utils/error-body.ts +++ b/packages/ai/src/utils/error-body.ts @@ -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`) → diff --git a/packages/ai/test/error-body.test.ts b/packages/ai/test/error-body.test.ts index c2824e8be..b6e236770 100644 --- a/packages/ai/test/error-body.test.ts +++ b/packages/ai/test/error-body.test.ts @@ -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((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + await new Promise((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}`, + ); + }); +});