Skip to content

Commit e01a615

Browse files
authored
fix(boxel-cli): Extract fetch error.cause on publish/unpublish failures (#4925)
1 parent 71b78fb commit e01a615

4 files changed

Lines changed: 93 additions & 4 deletions

File tree

packages/boxel-cli/src/commands/realm/publish.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '../../lib/profile-manager';
88
import { unpublishRealm } from './unpublish';
99
import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
10+
import { describeFetchError } from '../../lib/describe-fetch-error';
1011

1112
const DEFAULT_TIMEOUT_MS = 300_000;
1213
const READINESS_POLL_INTERVAL_MS = 1000;
@@ -193,7 +194,7 @@ async function waitForPublishedRealmReady(
193194
}
194195
lastError = `HTTP ${response.status}`;
195196
} catch (error) {
196-
lastError = error instanceof Error ? error.message : String(error);
197+
lastError = describeFetchError(error);
197198
}
198199
let remaining = timeoutMs - (Date.now() - startedAt);
199200
if (remaining <= 0) break;
@@ -276,6 +277,14 @@ export function registerPublishCommand(realm: Command): void {
276277
console.error(
277278
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
278279
);
280+
// Node's fetch surfaces the actual transport error (ECONNRESET,
281+
// TLS failure, undici socket error, etc.) on `error.cause`. Print
282+
// it so opaque "fetch failed" messages don't strand the caller.
283+
// `!= null` rather than a truthy check so we don't drop
284+
// falsy-but-defined causes (`''`, `0`, `false`, `NaN`).
285+
if (err instanceof Error && err.cause != null) {
286+
console.error(`${FG_RED}Caused by:${RESET}`, err.cause);
287+
}
279288
process.exit(1);
280289
}
281290
},

packages/boxel-cli/src/commands/realm/unpublish.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type ProfileManager,
77
} from '../../lib/profile-manager';
88
import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
9+
import { describeFetchError } from '../../lib/describe-fetch-error';
910

1011
export interface UnpublishOptions {
1112
/**
@@ -67,9 +68,7 @@ export async function unpublishRealm(
6768
return {
6869
publishedRealmURL: normalized,
6970
unpublished: false,
70-
error: `Failed to reach realm server: ${
71-
err instanceof Error ? err.message : String(err)
72-
}`,
71+
error: `Failed to reach realm server: ${describeFetchError(err)}`,
7372
};
7473
}
7574

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Node's `fetch` error surface is shallow: the outer error is always
2+
// `TypeError: fetch failed`, and the *real* reason (ECONNRESET, TLS
3+
// failure, undici socket error, etc.) lives on `error.cause`. Inline
4+
// both when summarizing a failed fetch for log output or for embedding
5+
// in a result string returned from a higher-level operation, so that
6+
// opaque "fetch failed" lines don't reach the operator without context.
7+
//
8+
// `error.cause != null` rather than a truthy check so we don't drop
9+
// falsy-but-defined causes (`''`, `0`, `false`, `NaN`). `!= null`
10+
// matches both `null` and `undefined` — i.e., the absence markers —
11+
// and lets every explicit value through.
12+
//
13+
// For user-facing CLI output where the full nested Error (including
14+
// stack frames) is useful, prefer logging `err` and `err.cause` as
15+
// separate console.error arguments so Node pretty-prints them. This
16+
// helper is for the case where the output needs to be a single string.
17+
export function describeFetchError(error: unknown): string {
18+
let msg = error instanceof Error ? error.message : String(error);
19+
if (error instanceof Error && error.cause != null) {
20+
let cause = error.cause;
21+
let causeMsg = cause instanceof Error ? cause.message : String(cause);
22+
return `${msg} (caused by: ${causeMsg})`;
23+
}
24+
return msg;
25+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { describeFetchError } from '../../src/lib/describe-fetch-error';
3+
4+
describe('describeFetchError', () => {
5+
it('returns the message for a plain Error without a cause', () => {
6+
let err = new Error('boom');
7+
expect(describeFetchError(err)).toBe('boom');
8+
});
9+
10+
it('returns the string form for a non-Error value', () => {
11+
expect(describeFetchError('plain string')).toBe('plain string');
12+
expect(describeFetchError(42)).toBe('42');
13+
expect(describeFetchError(null)).toBe('null');
14+
expect(describeFetchError(undefined)).toBe('undefined');
15+
});
16+
17+
it('appends an Error cause with a (caused by: …) suffix', () => {
18+
// Build via assignment to sidestep the ErrorOptions TS lib target.
19+
let socketErr = new Error('ECONNRESET: socket hang up');
20+
let fetchErr = new TypeError('fetch failed') as TypeError & {
21+
cause?: unknown;
22+
};
23+
fetchErr.cause = socketErr;
24+
expect(describeFetchError(fetchErr)).toBe(
25+
'fetch failed (caused by: ECONNRESET: socket hang up)',
26+
);
27+
});
28+
29+
it('renders a non-Error cause via String()', () => {
30+
let err = new Error('outer') as Error & { cause?: unknown };
31+
err.cause = { code: 'ENOTFOUND' };
32+
expect(describeFetchError(err)).toBe('outer (caused by: [object Object])');
33+
});
34+
35+
it('preserves falsy-but-defined causes that a truthy check would drop', () => {
36+
// The behavior this guards is the difference between `error.cause`
37+
// (truthy check, drops falsy values) and `error.cause != null`
38+
// (preserves any explicit value). Verifies the four falsy
39+
// primitives a Promise.reject could plausibly carry.
40+
for (let cause of ['', 0, false, NaN]) {
41+
let err = new Error('outer') as Error & { cause?: unknown };
42+
err.cause = cause;
43+
expect(describeFetchError(err)).toBe(
44+
`outer (caused by: ${String(cause)})`,
45+
);
46+
}
47+
});
48+
49+
it('omits the (caused by: …) suffix for null or undefined causes', () => {
50+
for (let cause of [null, undefined]) {
51+
let err = new Error('outer') as Error & { cause?: unknown };
52+
err.cause = cause;
53+
expect(describeFetchError(err)).toBe('outer');
54+
}
55+
});
56+
});

0 commit comments

Comments
 (0)