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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await komodoCall(path, body);
|
res = await komodoCall(path, body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, kind: 'unreachable', message: (err as Error).message };
|
return { ok: false, kind: 'unreachable', message: describeFetchError(err, url) };
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` };
|
return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` };
|
||||||
@@ -94,6 +96,7 @@ export interface KomodoPortSuccess {
|
|||||||
ok: true;
|
ok: true;
|
||||||
port: number;
|
port: number;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
export interface KomodoPortFailureResult {
|
export interface KomodoPortFailureResult {
|
||||||
ok: false;
|
ok: false;
|
||||||
@@ -101,6 +104,9 @@ export interface KomodoPortFailureResult {
|
|||||||
detail: string;
|
detail: string;
|
||||||
httpStatus?: number;
|
httpStatus?: number;
|
||||||
snippet?: string;
|
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;
|
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)`;
|
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 {
|
function safeJsonStringify(value: unknown): string {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
@@ -161,11 +187,13 @@ export async function komodoResolvePortDetailed(serverName: string): Promise<Kom
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await komodoCall(path, body);
|
res = await komodoCall(path, body);
|
||||||
} catch (err) {
|
} 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
|
// 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',
|
reason: 'http-error',
|
||||||
detail: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
detail: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
||||||
httpStatus: res.status,
|
httpStatus: res.status,
|
||||||
snippet: truncate(rawText)
|
snippet: truncate(rawText),
|
||||||
|
url
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +220,8 @@ export async function komodoResolvePortDetailed(serverName: string): Promise<Kom
|
|||||||
reason: 'unparseable',
|
reason: 'unparseable',
|
||||||
detail: `response body is not JSON: ${(err as Error).message}`,
|
detail: `response body is not JSON: ${(err as Error).message}`,
|
||||||
httpStatus: res.status,
|
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',
|
reason: 'no-udp-in-response',
|
||||||
detail: 'parsed the response but no UDP port matched any known shape',
|
detail: 'parsed the response but no UDP port matched any known shape',
|
||||||
httpStatus: res.status,
|
httpStatus: res.status,
|
||||||
snippet
|
snippet,
|
||||||
|
url
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true, port, snippet };
|
return { ok: true, port, snippet, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
httpStatus?: number;
|
httpStatus?: number;
|
||||||
snippet?: string;
|
snippet?: string;
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
@@ -212,6 +213,12 @@
|
|||||||
{#if komodoProbe.httpStatus} · HTTP {komodoProbe.httpStatus}{/if}
|
{#if komodoProbe.httpStatus} · HTTP {komodoProbe.httpStatus}{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if komodoProbe.url}
|
||||||
|
<div class="probe-url">
|
||||||
|
<span class="k">tried</span>
|
||||||
|
<code>{komodoProbe.url}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if komodoProbe.reason}
|
{#if komodoProbe.reason}
|
||||||
<div class="hint">{komodoFixHint(komodoProbe.reason, komodoProbe.httpStatus)}</div>
|
<div class="hint">{komodoFixHint(komodoProbe.reason, komodoProbe.httpStatus)}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -505,6 +512,30 @@
|
|||||||
}
|
}
|
||||||
.probe-detail strong { color: var(--spark-500); }
|
.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 {
|
.snippet-details {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
Reference in New Issue
Block a user