From ec56b4115903512053d6d9d4cc749c6fe7644c8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 17:33:19 +0000 Subject: [PATCH] Add 30s timeout to all fetch requests via globalThis.fetch wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces src/lib/fetch-timeout.ts, a side-effect module that wraps globalThis.fetch with a 30-second AbortSignal.timeout. The wrapper preserves any caller-provided signal using AbortSignal.any, ensuring existing timeout-bounded calls (e.g. update-check, doctor GitHub fetch) continue to work correctly. The module is imported at the top of src/cli.ts so every outbound HTTP request — including Resend SDK calls routed through withSpinner — fails fast instead of blocking indefinitely on a stalled network path. Fixes BU-666 Co-authored-by: Bu Kinoshita --- src/cli.ts | 1 + src/lib/fetch-timeout.ts | 15 ++++++++ tests/lib/fetch-timeout.test.ts | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 src/lib/fetch-timeout.ts create mode 100644 tests/lib/fetch-timeout.test.ts diff --git a/src/cli.ts b/src/cli.ts index 922840bb..89b855d1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import './lib/fetch-timeout'; import { Command } from '@commander-js/extra-typings'; import pc from 'picocolors'; import { apiKeysCommand } from './commands/api-keys/index'; diff --git a/src/lib/fetch-timeout.ts b/src/lib/fetch-timeout.ts new file mode 100644 index 00000000..8d957fb5 --- /dev/null +++ b/src/lib/fetch-timeout.ts @@ -0,0 +1,15 @@ +export const REQUEST_TIMEOUT_MS = 30_000; + +const originalFetch = globalThis.fetch; + +globalThis.fetch = ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { + const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const signal = init?.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal; + + return originalFetch(input, { ...init, signal }); +}; diff --git a/tests/lib/fetch-timeout.test.ts b/tests/lib/fetch-timeout.test.ts new file mode 100644 index 00000000..b37f259b --- /dev/null +++ b/tests/lib/fetch-timeout.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('fetch-timeout', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetModules(); + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('attaches a timeout signal when no signal is provided', async () => { + const fakeFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.signal).toBeDefined(); + return Promise.resolve(new Response('ok')); + }); + globalThis.fetch = fakeFetch as typeof fetch; + + await import('../../src/lib/fetch-timeout'); + await globalThis.fetch('https://example.com'); + + expect(fakeFetch).toHaveBeenCalledOnce(); + }); + + it('preserves a caller-provided signal alongside the timeout', async () => { + const callerAbort = new AbortController(); + const fakeFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.signal).toBeDefined(); + expect(init?.signal).not.toBe(callerAbort.signal); + return Promise.resolve(new Response('ok')); + }); + globalThis.fetch = fakeFetch as typeof fetch; + + await import('../../src/lib/fetch-timeout'); + await globalThis.fetch('https://example.com', { + signal: callerAbort.signal, + }); + + expect(fakeFetch).toHaveBeenCalledOnce(); + }); + + it('forwards all init options to the underlying fetch', async () => { + const fakeFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ 'X-Test': '1' }); + return Promise.resolve(new Response('ok')); + }); + globalThis.fetch = fakeFetch as typeof fetch; + + await import('../../src/lib/fetch-timeout'); + await globalThis.fetch('https://example.com', { + method: 'POST', + headers: { 'X-Test': '1' }, + }); + + expect(fakeFetch).toHaveBeenCalledOnce(); + }); + + it('exports REQUEST_TIMEOUT_MS as 30000', async () => { + const mod = await import('../../src/lib/fetch-timeout'); + expect(mod.REQUEST_TIMEOUT_MS).toBe(30_000); + }); +});