Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/lib/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -67,7 +68,10 @@ export async function withSpinner<T>(
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 =
Expand Down
21 changes: 21 additions & 0 deletions src/lib/with-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const REQUEST_TIMEOUT_MS = 30_000;

const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> =>
new Promise<T>((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 };
26 changes: 26 additions & 0 deletions tests/lib/spinner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
23 changes: 23 additions & 0 deletions tests/lib/with-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>((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');
});
});
Loading