From 6fc434e1f09612f09902eb1b5804d7912a5510de Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Mon, 6 Jul 2026 18:03:51 -0700 Subject: [PATCH] fix: use Chromium network stack for API requests and explain fetch failures Main-process requests to da.live / api.aem.live went through Node's fetch (undici), which ignores the OS proxy/PAC configuration and certificate store. On corporate networks (proxy or TLS interception) every request failed with a bare "TypeError: fetch failed" while browsers on the same machine worked fine. - Route ContentApiClient and the preview proxy upstream fetches through Electron's net.fetch (Chromium network stack: system proxy + OS trust store). - Wrap transport failures so the error names the request and the underlying cause chain (DNS/TLS/proxy/timeout) instead of a bare "fetch failed", with a hint to check network/VPN/proxy. Co-Authored-By: Claude Fable 5 --- src/main/content-api-client.js | 22 +++++++++++++-- src/main/http-request-error.js | 40 ++++++++++++++++++++++++++ src/main/index.js | 9 ++++-- src/main/preview-server-registry.js | 2 ++ test/content-api-client.test.js | 42 +++++++++++++++++++++++++++- test/preview-server-registry.test.js | 5 +++- 6 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/main/content-api-client.js b/src/main/content-api-client.js index 4f47f03..b42b445 100644 --- a/src/main/content-api-client.js +++ b/src/main/content-api-client.js @@ -31,10 +31,28 @@ import { buildDaLiveSourceUrl, LIST_CONTINUATION_HEADER, } from './da-live-api.js'; -import { buildHttpError } from './http-request-error.js'; +import { buildFetchFailureError, buildHttpError } from './http-request-error.js'; const LIST_MAX_PAGES = 50000; +/** + * Wraps a fetch implementation so transport failures (DNS, TLS, proxy, + * offline) surface the request and underlying cause instead of a bare + * "TypeError: fetch failed". + * + * @param {typeof fetch} fetchImpl + * @returns {typeof fetch} + */ +function withNetworkErrorDetail(fetchImpl) { + return async (url, init) => { + try { + return await fetchImpl(url, init); + } catch (err) { + throw buildFetchFailureError(init?.method || 'GET', String(url), err); + } + }; +} + /** * Builds a 401 error that keeps the {@link DA_UNAUTHORIZED_MESSAGE} prefix * (session-expiry detection matches on it) while carrying the request, @@ -61,7 +79,7 @@ export class ContentApiClient { constructor(token, backend = API_BACKEND_DA_LIVE, fetchImpl = globalThis.fetch) { this.token = token; this.backend = isValidApiBackend(backend) ? backend : API_BACKEND_DA_LIVE; - this.fetch = fetchImpl; + this.fetch = withNetworkErrorDetail(fetchImpl); } get authHeader() { diff --git a/src/main/http-request-error.js b/src/main/http-request-error.js index 46663e6..9195ebb 100644 --- a/src/main/http-request-error.js +++ b/src/main/http-request-error.js @@ -68,6 +68,46 @@ export function composeHttpErrorMessage({ return messageParts.join(' — '); } +const MAX_CAUSE_DEPTH = 5; + +/** + * Flattens an error's `cause` chain into one line. Node's fetch (undici) + * reports network failures as a bare "TypeError: fetch failed" with the + * actual reason (DNS, TLS, proxy, timeout) hidden in nested causes. + * + * @param {unknown} err + * @returns {string} + */ +export function describeErrorChain(err) { + const parts = []; + let current = err; + while (current && parts.length < MAX_CAUSE_DEPTH) { + const message = current instanceof Error ? current.message : String(current); + const { code } = /** @type {{ code?: string }} */ (current); + const part = code && !message.includes(code) ? `${message} (${code})` : message; + if (part && !parts.includes(part)) { + parts.push(part); + } + current = /** @type {{ cause?: unknown }} */ (current).cause; + } + return parts.join(' ← ') || 'unknown error'; +} + +/** + * Builds the error for a fetch that failed before any HTTP response arrived + * (DNS, TLS, proxy, offline), naming the request and the underlying cause. + * + * @param {string} method + * @param {string} url + * @param {unknown} err + * @returns {HttpRequestError} + */ +export function buildFetchFailureError(method, url, err) { + const message = `Network request failed: ${method} ${url} — ${describeErrorChain(err)}. ` + + 'Check your network, VPN, or proxy connection.'; + return new HttpRequestError(message, { method, url }); +} + /** * @param {string} method * @param {string} url diff --git a/src/main/index.js b/src/main/index.js index a94f8fc..c541e3d 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import { - app, BrowserWindow, dialog, ipcMain, session, shell, + app, BrowserWindow, dialog, ipcMain, net, session, shell, } from 'electron'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; @@ -288,6 +288,10 @@ async function handleDaUnauthorized(err, site, backend) { return new Error(detail); } +// Chromium network stack (not Node's undici): honors the OS proxy/PAC +// configuration and certificate store, which corporate networks require. +const chromiumFetch = (url, init) => net.fetch(url, init); + async function withContentClient(site, fn) { const backend = site.apiBackend || API_BACKEND_DA_LIVE; try { @@ -295,7 +299,7 @@ async function withContentClient(site, fn) { // missing/expired token throws an unauthorized error with the reason, // and the renderer routes the user to the explicit Sign in button. const accessToken = await resolveStoredAccessToken(tokenPath()); - return await fn(new ContentApiClient(accessToken, backend)); + return await fn(new ContentApiClient(accessToken, backend, chromiumFetch)); } catch (err) { if (isDaUnauthorizedError(err)) { throw await handleDaUnauthorized(err, site, backend); @@ -996,6 +1000,7 @@ app.whenReady().then(async () => { getSyncFolder: () => loadSyncFolder(syncFolderStorePath()), resolveActiveSite: resolvePreviewSite, getToken: getSiteTokenFor, + fetchFn: chromiumFetch, onAuthRequired: (site) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('preview:auth-required', { diff --git a/src/main/preview-server-registry.js b/src/main/preview-server-registry.js index d2f85b1..685e415 100644 --- a/src/main/preview-server-registry.js +++ b/src/main/preview-server-registry.js @@ -48,6 +48,7 @@ export function previewUrlOrigin(previewUrl) { * previewUrl: string, * apiBackend?: string, * }|null>, + * fetchFn?: typeof fetch, * log?: import('electron-log').MainLogger, * }} deps */ @@ -96,6 +97,7 @@ export function createPreviewServerRegistry(deps) { getSyncFolder: deps.getSyncFolder, getToken: deps.getToken, onAuthRequired: deps.onAuthRequired, + fetchFn: deps.fetchFn, }); const entry = { diff --git a/test/content-api-client.test.js b/test/content-api-client.test.js index 536352c..1da3146 100644 --- a/test/content-api-client.test.js +++ b/test/content-api-client.test.js @@ -17,7 +17,11 @@ import { isDaUnauthorizedError, } from '../src/main/content-api-shared.js'; import { ContentApiClient } from '../src/main/content-api-client.js'; -import { composeHttpErrorMessage, HttpRequestError } from '../src/main/http-request-error.js'; +import { + composeHttpErrorMessage, + describeErrorChain, + HttpRequestError, +} from '../src/main/http-request-error.js'; test('composeHttpErrorMessage includes x-error header', () => { const message = composeHttpErrorMessage({ @@ -33,6 +37,42 @@ test('composeHttpErrorMessage includes x-error header', () => { assert.match(message, /400 Bad Request/); }); +test('describeErrorChain flattens nested fetch failure causes', () => { + const dns = Object.assign(new Error('getaddrinfo ENOTFOUND admin.da.live'), { + code: 'ENOTFOUND', + }); + const fetchFailed = new TypeError('fetch failed', { cause: dns }); + + assert.equal( + describeErrorChain(fetchFailed), + 'fetch failed ← getaddrinfo ENOTFOUND admin.da.live', + ); + assert.equal(describeErrorChain(new Error('boom')), 'boom'); + assert.equal(describeErrorChain(null), 'unknown error'); +}); + +test('network failures name the request and underlying cause', async () => { + const dns = Object.assign(new Error('getaddrinfo ENOTFOUND admin.da.live'), { + code: 'ENOTFOUND', + }); + const fetchImpl = async () => { + throw new TypeError('fetch failed', { cause: dns }); + }; + const client = new ContentApiClient('token', API_BACKEND_DA_LIVE, fetchImpl); + + await assert.rejects( + () => client.list('org', 'site', '/'), + (err) => { + assert.ok(err instanceof HttpRequestError); + assert.match(err.message, /Network request failed: GET https:\/\/admin\.da\.live\/list\/org\/site\//); + assert.match(err.message, /getaddrinfo ENOTFOUND admin\.da\.live/); + assert.match(err.message, /network, VPN, or proxy/); + assert.ok(!isDaUnauthorizedError(err)); + return true; + }, + ); +}); + test('401 errors keep the unauthorized prefix and carry request detail', async () => { const fetchImpl = async () => new Response('token rejected', { status: 401, diff --git a/test/preview-server-registry.test.js b/test/preview-server-registry.test.js index de5411c..3e0ca74 100644 --- a/test/preview-server-registry.test.js +++ b/test/preview-server-registry.test.js @@ -25,15 +25,18 @@ test('previewUrlOrigin extracts origin from preview URL', () => { test('preview server registry assigns ports per upstream origin', async () => { let nextPort = 5000; + const customFetch = async () => new Response(''); const registry = createPreviewServerRegistry({ - startPreviewServer: async () => { + startPreviewServer: async (opts) => { + assert.equal(opts.fetchFn, customFetch, 'fetchFn should reach each preview server'); nextPort += 1; return { baseUrl: `http://127.0.0.1:${nextPort}`, close: async () => {}, }; }, + fetchFn: customFetch, createHeadHtmlCache: () => ({ clear: () => {}, resolve: async () => '',