Unwrap fetch errors + show URL in Komodo probe result

The probe surfaced "unreachable: This operation was aborted" — technically
correct (an AbortController fired the fetch timeout) but useless for
diagnosing whether it's a wrong URL, blocked route, or firewall. Node's
fetch also hides real network causes (ECONNREFUSED, ENOTFOUND,
EHOSTUNREACH) under `.cause`, and the default message just says
"fetch failed".

describeFetchError() now unwraps AbortError to "timed out after 10000ms
dialing <url>" and pulls `.cause.code/.cause.message` for network
failures. KomodoPortResult gains a `url` field, always populated, so the
probe panel can surface the exact URL we dialed in a code block —
operators can then `curl` it from the container to confirm reachability.

komodoRestart benefits from the same unwrapping since it uses the same
komodoCall() transport.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 09:28:15 -04:00
co-authored by Claude Opus 4.7
parent 1d05f5df1b
commit 08de5a4064
2 changed files with 68 additions and 6 deletions
+37 -6
View File
@@ -56,11 +56,13 @@ export async function komodoRestart(serverName: string): Promise<KomodoRestartRe
break;
}
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
let res: Response;
try {
res = await komodoCall(path, body);
} catch (err) {
return { ok: false, kind: 'unreachable', message: (err as Error).message };
return { ok: false, kind: 'unreachable', message: describeFetchError(err, url) };
}
if (!res.ok) {
return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` };
@@ -94,6 +96,7 @@ export interface KomodoPortSuccess {
ok: true;
port: number;
snippet: string;
url: string;
}
export interface KomodoPortFailureResult {
ok: false;
@@ -101,6 +104,9 @@ export interface KomodoPortFailureResult {
detail: string;
httpStatus?: number;
snippet?: string;
// URL we tried (or would have tried) — surfaced in the UI so operators
// can `curl` it from the container to verify reachability.
url?: string;
}
export type KomodoPortResult = KomodoPortSuccess | KomodoPortFailureResult;
@@ -111,6 +117,26 @@ function truncate(s: string): string {
return s.slice(0, SNIPPET_LIMIT) + `\n… (${s.length - SNIPPET_LIMIT} more bytes)`;
}
/**
* Turn a Node/Undici fetch failure into something an operator can act on.
* The default `.message` for an aborted fetch is just "This operation was
* aborted" — useless. For network failures Node hides the real cause under
* `.cause` (e.g. `ECONNREFUSED`, `ENOTFOUND`, `EHOSTUNREACH`). Unwrap both
* and always include the URL we tried.
*/
function describeFetchError(err: unknown, url: string): string {
const e = err as (Error & { name?: string; cause?: unknown }) | undefined;
if (e?.name === 'AbortError') {
return `timed out after ${config.httpTimeoutMs}ms dialing ${url}`;
}
const cause = e?.cause as (Error & { code?: string }) | undefined;
if (cause && (cause.code || cause.message)) {
const code = cause.code ? `${cause.code}: ` : '';
return `${code}${cause.message ?? 'network error'} (dialing ${url})`;
}
return `${e?.message ?? String(err)} (dialing ${url})`;
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
@@ -161,11 +187,13 @@ export async function komodoResolvePortDetailed(serverName: string): Promise<Kom
break;
}
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
let res: Response;
try {
res = await komodoCall(path, body);
} catch (err) {
return { ok: false, reason: 'unreachable', detail: (err as Error).message };
return { ok: false, reason: 'unreachable', detail: describeFetchError(err, url), url };
}
// Read the body up front — we want the snippet even on non-2xx so the
@@ -178,7 +206,8 @@ export async function komodoResolvePortDetailed(serverName: string): Promise<Kom
reason: 'http-error',
detail: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
httpStatus: res.status,
snippet: truncate(rawText)
snippet: truncate(rawText),
url
};
}
@@ -191,7 +220,8 @@ export async function komodoResolvePortDetailed(serverName: string): Promise<Kom
reason: 'unparseable',
detail: `response body is not JSON: ${(err as Error).message}`,
httpStatus: res.status,
snippet: truncate(rawText)
snippet: truncate(rawText),
url
};
}
@@ -203,10 +233,11 @@ export async function komodoResolvePortDetailed(serverName: string): Promise<Kom
reason: 'no-udp-in-response',
detail: 'parsed the response but no UDP port matched any known shape',
httpStatus: res.status,
snippet
snippet,
url
};
}
return { ok: true, port, snippet };
return { ok: true, port, snippet, url };
}
/**
+31
View File
@@ -16,6 +16,7 @@
detail?: string;
httpStatus?: number;
snippet?: string;
url?: string;
}
interface Server {
@@ -212,6 +213,12 @@
{#if komodoProbe.httpStatus} · HTTP {komodoProbe.httpStatus}{/if}
</span>
</div>
{#if komodoProbe.url}
<div class="probe-url">
<span class="k">tried</span>
<code>{komodoProbe.url}</code>
</div>
{/if}
{#if komodoProbe.reason}
<div class="hint">{komodoFixHint(komodoProbe.reason, komodoProbe.httpStatus)}</div>
{/if}
@@ -505,6 +512,30 @@
}
.probe-detail strong { color: var(--spark-500); }
.probe-url {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 8px;
padding: 6px 12px;
background: var(--cherry-875);
border-radius: var(--r-sm);
border: 1px solid var(--cherry-700);
font-size: 11px;
}
.probe-url .k {
color: var(--ink-500);
font-family: var(--font-body);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
.probe-url code {
color: var(--cream);
font-family: var(--font-mono);
word-break: break-all;
}
.snippet-details {
margin-top: 10px;
font-family: var(--font-mono);