codeburn/src/fetch-utils.ts
Resham Joshi f57ad64ce7
fix(network): add timeouts to critical-path fetches (#445) (#448)
The pricing fetch (loadPricing -> fetchAndCachePricing) runs on every CLI
invocation, and the macOS menubar shells out to the CLI and blocks on its
exit. fetch() had no timeout, so a half-open network after wake-from-sleep
made it hang forever once the 24h pricing cache expired — wedging the
menubar on its loading spinner until relaunch.

Add a shared fetchWithTimeout helper (8s default, AbortSignal.timeout) and
apply it to the two daily-critical-path fetches: pricing (models.ts) and
the currency rate (currency.ts). On timeout the existing catch falls back
to the bundled price snapshot / cached or USD rate.

Reproduced on main (stale cache + black-holed host -> hangs indefinitely);
with the timeout the same scenario aborts in 8s and renders via fallback.
2026-06-06 03:25:36 +02:00

22 lines
1.1 KiB
TypeScript

// Default ceiling for outbound HTTP. Every CLI command awaits loadPricing(),
// and the macOS menubar shells out to the CLI and blocks on its exit — so an
// unbounded fetch() on a half-open network (e.g. Wi-Fi/DNS not yet up after
// wake-from-sleep) wedges the menubar on its loading spinner indefinitely.
// 8s is generous for these small JSON endpoints while still failing fast.
export const DEFAULT_FETCH_TIMEOUT_MS = 8000
/// fetch() with a hard timeout. On timeout the returned promise rejects with a
/// TimeoutError (an AbortError subtype), which callers already handle via their
/// existing try/catch + bundled-snapshot fallback. A caller-supplied signal is
/// combined with the timeout so either can abort the request.
export async function fetchWithTimeout(
url: string,
init: RequestInit = {},
timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
): Promise<Response> {
const timeoutSignal = AbortSignal.timeout(timeoutMs)
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal
return fetch(url, { ...init, signal })
}