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:
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user