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
18 changes: 18 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ export const NETWORK_CODES = new Set([
"UND_ERR_SOCKET",
]);

/** TLS failures are different from offline/network failures. Keep verification on. */
const TLS_CA_CODES = new Set([
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"SELF_SIGNED_CERT_IN_CHAIN",
]);

export function tlsCaHint(err: unknown): string | null {
if (!(err instanceof Error)) return null;
const code = (err.cause as { code?: unknown } | undefined)?.code;
if (typeof code !== "string" || !TLS_CA_CODES.has(code)) return null;
return process.platform === "win32"
? "TLS certificate trust failed — set NODE_USE_SYSTEM_CA=1 for Aether Agent so Node uses the Windows trust store"
: "TLS certificate trust failed — install the trusted issuer or configure NODE_EXTRA_CA_CERTS";
}

/**
* True when `err` is the client-side cancellation of an in-flight turn
* (AbortController fired). Undici surfaces this two ways depending on where
Expand Down Expand Up @@ -198,6 +214,8 @@ export function httpStatusHint(status: number): string | null {
* actionable next step at all.
*/
export function nonHttpErrorHint(err: unknown): string | null {
const tlsHint = tlsCaHint(err);
if (tlsHint !== null) return tlsHint;
if (err instanceof MalformedResponseError) return "retry, or /doctor to check connectivity";
if (err instanceof StreamEventTooLargeError) return "retry, or /doctor to inspect the server stream";
if (err instanceof StreamTimeoutError) return "the stream went quiet - retry, or /doctor to check connectivity";
Expand Down
10 changes: 10 additions & 0 deletions test/error_hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ test("network failures point at connectivity", () => {
assert.match(hintFor(new Error("connect ECONNREFUSED 1.2.3.4:443"))!, /network/);
});

test("TLS trust failures get a secure CA repair rather than a network hint", () => {
const tls = new TypeError("fetch failed", {
cause: { code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE" },
});
const hint = hintFor(tls) ?? "";
assert.match(hint, /TLS certificate trust failed/);
assert.match(hint, process.platform === "win32" ? /NODE_USE_SYSTEM_CA=1/ : /NODE_EXTRA_CA_CERTS/);
assert.doesNotMatch(hint, /TLS_REJECT_UNAUTHORIZED/);
});

// LOOP-06 round 3: undici puts the failure code on err.cause.code, not in
// the message text, for real fetch failures — errors.errorHint already
// checked this via NETWORK_CODES; hintFor only pattern-matched the message
Expand Down
9 changes: 9 additions & 0 deletions test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ test("network failures hint at /doctor with the base url", () => {
assert.match(h, /offline\?/);
});

test("REPL TLS trust failure gives the same CA repair", () => {
const tls = new TypeError("fetch failed", {
cause: { code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE" },
});
const hint = errorHint(tls, BASE) ?? "";
assert.match(hint, /TLS certificate trust failed/);
assert.match(hint, process.platform === "win32" ? /NODE_USE_SYSTEM_CA=1/ : /NODE_EXTRA_CA_CERTS/);
});

test("stream timeouts get a retry/doctor hint, matching error_hints.hintFor (LOOP-06 round 2)", () => {
// Regression for LOOP-06 round 2: errorHint used to have no branch for
// StreamTimeoutError, so it fell through to the generic Error branch (no
Expand Down
Loading