Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,7 +80,9 @@ function getRateCachePath(): string {
}

async function fetchRate(code: string): Promise<number> {
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<string, unknown> }
const rate = data.rates?.[code]
Expand Down
22 changes: 22 additions & 0 deletions src/fetch-utils.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
const timeoutSignal = AbortSignal.timeout(timeoutMs)
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal
return fetch(url, { ...init, signal })
}
7 changes: 6 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,7 +95,11 @@ function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
}

async function fetchAndCachePricing(): Promise<Map<string, ModelCosts>> {
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<string, LiteLLMEntry>
const pricing = new Map<string, ModelCosts>()
Expand Down
51 changes: 51 additions & 0 deletions tests/fetch-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(resolve => server?.close(() => resolve()))
})

function listen(handler: (respond: () => void) => void): Promise<string> {
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' })
})
})
Loading