mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 17:49:23 +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.
51 lines
1.8 KiB
TypeScript
51 lines
1.8 KiB
TypeScript
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' })
|
|
})
|
|
})
|