diff --git a/src/lib/spinner.ts b/src/lib/spinner.ts index 88e4134..1d4edcf 100644 --- a/src/lib/spinner.ts +++ b/src/lib/spinner.ts @@ -2,6 +2,7 @@ import pc from 'picocolors'; import type { GlobalOpts } from './client'; import { errorMessage, outputError } from './output'; import { isInteractive, isUnicodeSupported } from './tty'; +import { REQUEST_TIMEOUT_MS, withTimeout } from './with-timeout'; // Status symbols generated via String.fromCodePoint() — never literal Unicode in // source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. @@ -67,7 +68,10 @@ export async function withSpinner( const spinner = createSpinner(loading, globalOpts.quiet); try { for (let attempt = 0; ; attempt++) { - const { data, error, headers } = await call(); + const { data, error, headers } = await withTimeout( + call(), + REQUEST_TIMEOUT_MS, + ); if (error) { if (attempt < MAX_RETRIES && error.name === 'rate_limit_exceeded') { const delay = diff --git a/src/lib/with-timeout.ts b/src/lib/with-timeout.ts new file mode 100644 index 0000000..3994209 --- /dev/null +++ b/src/lib/with-timeout.ts @@ -0,0 +1,21 @@ +const REQUEST_TIMEOUT_MS = 30_000; + +const withTimeout = (promise: Promise, ms: number): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Request timed out after ${ms / 1000}s`)); + }, ms); + timer.unref(); + promise.then( + (val) => { + clearTimeout(timer); + resolve(val); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); + +export { REQUEST_TIMEOUT_MS, withTimeout }; diff --git a/tests/lib/spinner.test.ts b/tests/lib/spinner.test.ts index 395c630..427ff54 100644 --- a/tests/lib/spinner.test.ts +++ b/tests/lib/spinner.test.ts @@ -8,6 +8,7 @@ import { vi, } from 'vitest'; import { withSpinner } from '../../src/lib/spinner'; +import * as timeoutModule from '../../src/lib/with-timeout'; import { captureTestEnv, ExitError, @@ -187,6 +188,31 @@ describe('withSpinner retry on rate_limit_exceeded', () => { // Default first retry delay is 1s expect(Date.now() - start).toBeGreaterThanOrEqual(900); }); + + test('exits with error when request times out', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(timeoutModule, 'withTimeout').mockRejectedValue( + new Error('Request timed out after 30s'), + ); + + let threw = false; + try { + await withSpinner( + msgs, + async () => ({ data: null, error: null, headers: null }), + 'test_error', + globalOpts, + ); + } catch (err) { + threw = true; + expect(err).toBeInstanceOf(ExitError); + expect((err as ExitError).code).toBe(1); + } + expect(threw).toBe(true); + const logOutput = logSpy.mock.calls.flat().join(' '); + expect(logOutput).toContain('timed out'); + logSpy.mockRestore(); + }); }); describe('createSpinner', () => { diff --git a/tests/lib/with-timeout.test.ts b/tests/lib/with-timeout.test.ts new file mode 100644 index 0000000..125dcbd --- /dev/null +++ b/tests/lib/with-timeout.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { withTimeout } from '../../src/lib/with-timeout'; + +describe('withTimeout', () => { + it('resolves when the promise completes before the deadline', async () => { + const result = await withTimeout(Promise.resolve(42), 1000); + expect(result).toBe(42); + }); + + it('rejects when the promise exceeds the deadline', async () => { + const slow = new Promise((resolve) => + setTimeout(() => resolve('late'), 5000), + ); + await expect(withTimeout(slow, 50)).rejects.toThrow( + 'Request timed out after 0.05s', + ); + }); + + it('forwards the original rejection when the promise fails before the deadline', async () => { + const failing = Promise.reject(new Error('boom')); + await expect(withTimeout(failing, 1000)).rejects.toThrow('boom'); + }); +});