From f61ca2072b8580164c16ee82a38102133cbb8075 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 26 Apr 2026 19:20:32 +0800 Subject: [PATCH] fix(ai): unwrap fetch error.cause for actionable network failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../web/server/__tests__/provider-url.test.ts | 63 +++++++++++++++++++ apps/web/server/api/ai/chat.ts | 8 ++- apps/web/server/api/ai/provider-models.ts | 5 +- apps/web/server/api/ai/provider-url.ts | 41 ++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/apps/web/server/__tests__/provider-url.test.ts b/apps/web/server/__tests__/provider-url.test.ts index 7db95f3e..9597d285 100644 --- a/apps/web/server/__tests__/provider-url.test.ts +++ b/apps/web/server/__tests__/provider-url.test.ts @@ -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', + ); + }); +}); diff --git a/apps/web/server/api/ai/chat.ts b/apps/web/server/api/ai/chat.ts index 965e9304..398a1613 100644 --- a/apps/web/server/api/ai/chat.ts +++ b/apps/web/server/api/ai/chat.ts @@ -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`), ); diff --git a/apps/web/server/api/ai/provider-models.ts b/apps/web/server/api/ai/provider-models.ts index f5168324..da7a2cf5 100644 --- a/apps/web/server/api/ai/provider-models.ts +++ b/apps/web/server/api/ai/provider-models.ts @@ -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) }; } }); diff --git a/apps/web/server/api/ai/provider-url.ts b/apps/web/server/api/ai/provider-url.ts index e3c13f95..a4c6e0d7 100644 --- a/apps/web/server/api/ai/provider-url.ts +++ b/apps/web/server/api/ai/provider-url.ts @@ -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 []; +}