fix(ai): unwrap fetch error.cause for actionable network failures

Custom OpenAI-compatible providers surfaced Node's opaque
`TypeError: fetch failed` whenever the upstream HTTP call failed
(#121) — DNS, TLS handshake, connection refused, timeout — all
collapsed to the same useless string. The actual reason was already
on `error.cause` as a SystemError but never reached the user.

Add `formatFetchError()` that walks the cause chain (including
AggregateError emitted when undici tries multiple A records and each
attempt fails) and prefixes the SystemError code so users see
`ENOTFOUND: getaddrinfo ENOTFOUND api.foo.com` or
`ECONNREFUSED: connect ECONNREFUSED 127.0.0.1:443` instead of
`fetch failed`. Wire it into the model-list proxy (most common
trigger from the AI Settings dialog) and the builtin chat stream.

Closes #121
This commit is contained in:
Kayshen-X 2026-04-26 19:20:32 +08:00
parent c4e5359596
commit f61ca2072b
4 changed files with 112 additions and 5 deletions

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildProviderModelsURL,
formatFetchError,
normalizeBaseURL,
normalizeMemberBaseURL,
normalizeOptionalBaseURL,
@ -63,3 +64,65 @@ describe('provider-url helpers', () => {
);
});
});
describe('formatFetchError', () => {
it('unwraps undici "fetch failed" by reading error.cause', () => {
const cause = Object.assign(new Error('Client network socket disconnected'), {
code: 'ECONNRESET',
});
const err = Object.assign(new TypeError('fetch failed'), { cause });
expect(formatFetchError(err)).toBe('ECONNRESET: Client network socket disconnected');
});
it('skips the prefix when the cause message already contains the code', () => {
const cause = Object.assign(new Error('getaddrinfo ENOTFOUND api.example.com'), {
code: 'ENOTFOUND',
});
const err = Object.assign(new TypeError('fetch failed'), { cause });
expect(formatFetchError(err)).toBe('getaddrinfo ENOTFOUND api.example.com');
});
it('returns just the cause code when message is missing', () => {
const cause = Object.assign(new Error(''), { code: 'ECONNREFUSED' });
const err = Object.assign(new TypeError('fetch failed'), { cause });
expect(formatFetchError(err)).toBe('ECONNREFUSED');
});
it('returns just the cause message when no code', () => {
const cause = new Error('self-signed certificate in certificate chain');
const err = Object.assign(new TypeError('fetch failed'), { cause });
expect(formatFetchError(err)).toBe('self-signed certificate in certificate chain');
});
it('falls back to error.message when no cause', () => {
expect(formatFetchError(new Error('Provider returned 401'))).toBe('Provider returned 401');
});
it('handles non-Error inputs', () => {
expect(formatFetchError('boom')).toBe('Unknown error');
expect(formatFetchError(undefined)).toBe('Unknown error');
});
it('walks nested cause chains', () => {
const root = Object.assign(new Error('Hostname does not match certificate'), {
code: 'ERR_TLS_CERT_ALTNAME_INVALID',
});
const wrapper = Object.assign(new Error('TLS handshake failed'), { cause: root });
const outer = Object.assign(new TypeError('fetch failed'), { cause: wrapper });
expect(formatFetchError(outer)).toBe(
'ERR_TLS_CERT_ALTNAME_INVALID: Hostname does not match certificate',
);
});
it('unwraps AggregateError so per-IP attempt reasons reach the user', () => {
const a = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8080'), {
code: 'ECONNREFUSED',
});
const b = Object.assign(new Error('connect ECONNREFUSED ::1:8080'), { code: 'ECONNREFUSED' });
const agg = Object.assign(new AggregateError([a, b], 'all attempts failed'));
const err = Object.assign(new TypeError('fetch failed'), { cause: agg });
expect(formatFetchError(err)).toBe(
'connect ECONNREFUSED 127.0.0.1:8080; connect ECONNREFUSED ::1:8080',
);
});
});

View file

@ -10,7 +10,11 @@ import {
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env';
import { normalizeOptionalBaseURL, requireOpenAICompatBaseURL } from './provider-url';
import {
formatFetchError,
normalizeOptionalBaseURL,
requireOpenAICompatBaseURL,
} from './provider-url';
// SENSITIVE_LOG_PATTERN + readDebugTail are now canonical in @zseven-w/pen-mcp.
// Re-export here to keep existing consumers (tests, other modules) working.
import { SENSITIVE_LOG_PATTERN, readDebugTail } from '@zseven-w/pen-mcp';
@ -1101,7 +1105,7 @@ function streamViaBuiltin(body: ChatBody) {
destroyProvider(builtinProvider);
}
} catch (error) {
const content = error instanceof Error ? error.message : 'Unknown error';
const content = formatFetchError(error);
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
);

View file

@ -1,5 +1,5 @@
import { defineEventHandler, readBody } from 'h3';
import { buildProviderModelsURL, normalizeOptionalBaseURL } from './provider-url';
import { buildProviderModelsURL, formatFetchError, normalizeOptionalBaseURL } from './provider-url';
interface ProviderModelsBody {
baseURL: string;
@ -63,7 +63,6 @@ export default defineEventHandler(async (event) => {
return { models };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return { models: [], error: message };
return { models: [], error: formatFetchError(err) };
}
});

View file

@ -63,3 +63,44 @@ export function normalizeMemberBaseURL(
export function buildProviderModelsURL(baseURL: string): string {
return `${normalizeOpenAICompatBaseURL(baseURL) ?? normalizeBaseURL(baseURL)}/models`;
}
/**
* Node's `fetch` (undici) collapses every network-level failure DNS, TLS,
* refused connection, timeout into a single opaque `TypeError: fetch failed`.
* The real reason lives on `error.cause` as a SystemError with `code`/`syscall`/
* `hostname`. Surface that so users can act on it ("ENOTFOUND api.foo.com",
* "self-signed certificate", "ECONNREFUSED 127.0.0.1:443") instead of staring
* at "fetch failed".
*
* Walks the `cause` chain (some failures wrap multiple times) and unwraps
* AggregateError (undici emits one when DNS returns multiple A records and
* every connect attempt fails) so each leaf reason makes it to the user.
*/
export function formatFetchError(error: unknown): string {
const reasons = collectFetchErrorReasons(error);
if (reasons.length === 0) {
return error instanceof Error ? error.message || 'Unknown error' : 'Unknown error';
}
return Array.from(new Set(reasons)).join('; ');
}
function collectFetchErrorReasons(error: unknown, depth = 0): string[] {
if (depth > 5 || !(error instanceof Error)) return [];
const aggregate = (error as { errors?: unknown }).errors;
if (Array.isArray(aggregate) && aggregate.length > 0) {
return aggregate.flatMap((sub) => collectFetchErrorReasons(sub, depth + 1));
}
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof Error) {
return collectFetchErrorReasons(cause, depth + 1);
}
const code = (error as { code?: string }).code;
const message = error.message?.trim();
if (code && message && !message.includes(code)) return [`${code}: ${message}`];
if (message) return [message];
if (code) return [code];
return [];
}