mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
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.
This commit is contained in:
parent
77340e52b8
commit
f57ad64ce7
4 changed files with 83 additions and 2 deletions
|
|
@ -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<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]
|
||||
|
|
|
|||
22
src/fetch-utils.ts
Normal file
22
src/fetch-utils.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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<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>()
|
||||
|
|
|
|||
51
tests/fetch-utils.test.ts
Normal file
51
tests/fetch-utils.test.ts
Normal file
|
|
@ -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…
Add table
Add a link
Reference in a new issue