Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/main/content-api-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() {
Expand Down
40 changes: 40 additions & 0 deletions src/main/http-request-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -288,14 +288,18 @@ 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 {
// Never trigger a silent browser login from a background request: a
// 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);
Expand Down Expand Up @@ -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', {
Expand Down
2 changes: 2 additions & 0 deletions src/main/preview-server-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function previewUrlOrigin(previewUrl) {
* previewUrl: string,
* apiBackend?: string,
* }|null>,
* fetchFn?: typeof fetch,
* log?: import('electron-log').MainLogger,
* }} deps
*/
Expand Down Expand Up @@ -96,6 +97,7 @@ export function createPreviewServerRegistry(deps) {
getSyncFolder: deps.getSyncFolder,
getToken: deps.getToken,
onAuthRequired: deps.onAuthRequired,
fetchFn: deps.fetchFn,
});

const entry = {
Expand Down
42 changes: 41 additions & 1 deletion test/content-api-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion test/preview-server-registry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => '',
Expand Down
Loading