mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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:
parent
c4e5359596
commit
f61ca2072b
4 changed files with 112 additions and 5 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
buildProviderModelsURL,
|
buildProviderModelsURL,
|
||||||
|
formatFetchError,
|
||||||
normalizeBaseURL,
|
normalizeBaseURL,
|
||||||
normalizeMemberBaseURL,
|
normalizeMemberBaseURL,
|
||||||
normalizeOptionalBaseURL,
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ import {
|
||||||
buildSpawnClaudeCodeProcess,
|
buildSpawnClaudeCodeProcess,
|
||||||
getClaudeAgentDebugFilePath,
|
getClaudeAgentDebugFilePath,
|
||||||
} from '../../utils/resolve-claude-agent-env';
|
} 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.
|
// SENSITIVE_LOG_PATTERN + readDebugTail are now canonical in @zseven-w/pen-mcp.
|
||||||
// Re-export here to keep existing consumers (tests, other modules) working.
|
// Re-export here to keep existing consumers (tests, other modules) working.
|
||||||
import { SENSITIVE_LOG_PATTERN, readDebugTail } from '@zseven-w/pen-mcp';
|
import { SENSITIVE_LOG_PATTERN, readDebugTail } from '@zseven-w/pen-mcp';
|
||||||
|
|
@ -1101,7 +1105,7 @@ function streamViaBuiltin(body: ChatBody) {
|
||||||
destroyProvider(builtinProvider);
|
destroyProvider(builtinProvider);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const content = error instanceof Error ? error.message : 'Unknown error';
|
const content = formatFetchError(error);
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineEventHandler, readBody } from 'h3';
|
import { defineEventHandler, readBody } from 'h3';
|
||||||
import { buildProviderModelsURL, normalizeOptionalBaseURL } from './provider-url';
|
import { buildProviderModelsURL, formatFetchError, normalizeOptionalBaseURL } from './provider-url';
|
||||||
|
|
||||||
interface ProviderModelsBody {
|
interface ProviderModelsBody {
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
|
|
@ -63,7 +63,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return { models };
|
return { models };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
return { models: [], error: formatFetchError(err) };
|
||||||
return { models: [], error: message };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,44 @@ export function normalizeMemberBaseURL(
|
||||||
export function buildProviderModelsURL(baseURL: string): string {
|
export function buildProviderModelsURL(baseURL: string): string {
|
||||||
return `${normalizeOpenAICompatBaseURL(baseURL) ?? normalizeBaseURL(baseURL)}/models`;
|
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 [];
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue