diff --git a/src/currency.ts b/src/currency.ts index 144e60be..331d27de 100644 --- a/src/currency.ts +++ b/src/currency.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { homedir } from 'os' import { readConfig } from './config.js' +import { fetchWithTimeout } from './fetch-utils.js' type CurrencyState = { code: string @@ -79,7 +80,9 @@ function getRateCachePath(): string { } async function fetchRate(code: string): Promise { - const response = await fetch(`${FRANKFURTER_URL}${code}`) + // Bounded so a stalled network can't hang the daily refresh for non-USD users + // (same wedge as the pricing fetch); callers fall back to USD / cached rate. + const response = await fetchWithTimeout(`${FRANKFURTER_URL}${code}`) if (!response.ok) throw new Error(`HTTP ${response.status}`) const data = await response.json() as { rates?: Record } const rate = data.rates?.[code] diff --git a/src/fetch-utils.ts b/src/fetch-utils.ts new file mode 100644 index 00000000..7c18f74d --- /dev/null +++ b/src/fetch-utils.ts @@ -0,0 +1,22 @@ +// 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 { + const timeoutSignal = AbortSignal.timeout(timeoutMs) + const signal = init.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal + return fetch(url, { ...init, signal }) +} diff --git a/src/models.ts b/src/models.ts index 29a38893..5ad4fdac 100644 --- a/src/models.ts +++ b/src/models.ts @@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' import snapshotData from './data/litellm-snapshot.json' +import { fetchWithTimeout } from './fetch-utils.js' export type ModelCosts = { inputCostPerToken: number @@ -94,7 +95,11 @@ function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null { } async function fetchAndCachePricing(): Promise> { - const response = await fetch(LITELLM_URL) + // Bounded: runs on every CLI invocation (the menubar shells out and blocks on + // it). Without a timeout a half-open network after wake-from-sleep makes + // fetch() hang forever, wedging the menubar's loading spinner. On timeout the + // caller's catch falls back to the bundled price snapshot. + const response = await fetchWithTimeout(LITELLM_URL) if (!response.ok) throw new Error(`HTTP ${response.status}`) const data = await response.json() as Record const pricing = new Map() diff --git a/tests/fetch-utils.test.ts b/tests/fetch-utils.test.ts new file mode 100644 index 00000000..e613f8b3 --- /dev/null +++ b/tests/fetch-utils.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { createServer, type Server } from 'node:http' +import { type AddressInfo } from 'node:net' + +import { fetchWithTimeout } from '../src/fetch-utils.js' + +let server: Server + +afterEach(async () => { + await new Promise(resolve => server?.close(() => resolve())) +}) + +function listen(handler: (respond: () => void) => void): Promise { + return new Promise(resolve => { + server = createServer((_req, res) => handler(() => res.end('{"ok":true}'))) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve(`http://127.0.0.1:${port}/`) + }) + }) +} + +describe('fetchWithTimeout', () => { + it('aborts when the server never responds, within the timeout window', async () => { + // Accept the request but never reply — the half-open-network case. + const url = await listen(() => { /* never respond */ }) + + const start = Date.now() + await expect(fetchWithTimeout(url, {}, 150)).rejects.toMatchObject({ name: 'TimeoutError' }) + const elapsed = Date.now() - start + + // Fails fast at ~the timeout, not hanging indefinitely. + expect(elapsed).toBeLessThan(2000) + }) + + it('returns the response when the server replies before the timeout', async () => { + const url = await listen(respond => respond()) + + const res = await fetchWithTimeout(url, {}, 2000) + expect(res.ok).toBe(true) + expect(await res.json()).toEqual({ ok: true }) + }) + + it('still aborts on timeout when the caller also passes a signal', async () => { + const url = await listen(() => { /* never respond */ }) + const controller = new AbortController() + + await expect(fetchWithTimeout(url, { signal: controller.signal }, 150)) + .rejects.toMatchObject({ name: 'TimeoutError' }) + }) +})