mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
4cf69332c3
commit
4c992154d9
3 changed files with 73 additions and 2 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`) →
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue