diff --git a/README.md b/README.md index 0a75052..3cb60ad 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,45 @@ async function myFn () { }; ``` +### withTimeout + +Race a promise against a deadline. If the promise settles first, its result is returned; if the deadline passes first, the call rejects. By default that rejection is a `TimeoutError` whose message includes how long you waited; you can pass a non-empty string for a custom `TimeoutError` message, or an `Error` instance to reject with that exact value. Omitted or other falsy third arguments (except a real `Error`) use the default timeout message. + +`withTimeout` only stops *waiting* on the promise: the underlying async work keeps running (there is no built-in cancellation). Aborting a `fetch`, clearing timers, or tearing down other resources is the caller’s responsibility if you need that behavior. + +```js +import { withTimeout, TimeoutError } from 'asyncbox'; + +// default: TimeoutError, message includes timeoutMs +await withTimeout(fetch('/slow').then((r) => r.json()), 5000); + +// custom TimeoutError message +await withTimeout(slowWork(), 10_000, 'slowWork exceeded 10s'); + +// reject with a specific error you constructed (same object on timeout) +const err = new MyAppError('upstream did not respond'); +await withTimeout(slowWork(), 5000, err); +``` + +Cancelling an in-flight `fetch` when the timeout wins: + +```js +import { withTimeout, TimeoutError } from 'asyncbox'; + +async function fetchJsonWithTimeout(url, timeoutMs) { + const controller = new AbortController(); + const body = fetch(url, { signal: controller.signal }).then((r) => r.json()); + try { + return await withTimeout(body, timeoutMs); + } catch (err) { + if (err instanceof TimeoutError) { + controller.abort(); + } + throw err; + } +} +``` + ### Long Sleep Sometimes `Promise.delay` or `setTimeout` are inaccurate for large wait times. To safely wait for these long times (e.g. in the 5+ minute range), you can use `longSleep`: diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 7f1aaa6..806dfca 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -3,6 +3,14 @@ import type {LongSleepOptions, MapFilterOptions, WaitForConditionOptions} from ' const LONG_SLEEP_THRESHOLD = 5000; // anything over 5000ms will turn into a spin +/** Error thrown by {@link withTimeout} when the deadline is exceeded. */ +export class TimeoutError extends Error { + constructor(message?: string) { + super(message ?? 'Operation timed out'); + this.name = 'TimeoutError'; + } +} + /** * An async/await version of setTimeout * @param ms - The number of milliseconds to wait @@ -11,6 +19,48 @@ export async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Resolves with `promise` or rejects if it does not settle before `timeoutMs`. + * + * If the deadline passes first: with no third argument (or a falsy non-error value), rejects + * with {@link TimeoutError} whose message includes `timeoutMs`; with a non-empty string, + * rejects with {@link TimeoutError} using that message; with an `Error` instance, rejects + * with that same instance. + * + * @param promise - Promise to race against the timeout. + * @param timeoutMs - Maximum time in milliseconds before rejecting if `promise` has not settled. + * @param messageOrError - Optional override for the timeout rejection: custom {@link TimeoutError} + * message when the string is non-empty, or an existing `Error` to reject with verbatim. + * Omitted, `undefined`, or other falsy non-error values use the default timeout message (includes `timeoutMs`). + */ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + messageOrError?: string | Error, +): Promise { + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + if (typeof messageOrError === 'string' && messageOrError) { + reject(new TimeoutError(messageOrError)); + } else if (!messageOrError) { + reject(new TimeoutError(`Operation timed out after ${timeoutMs}ms`)); + } else { + reject(messageOrError); + } + }, timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + /** * Sometimes `Promise.delay` or `setTimeout` are inaccurate for large wait * times. To safely wait for these long times (e.g. in the 5+ minute range), you diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index 3298d4f..c8856de 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -8,6 +8,8 @@ import { asyncmap, asyncfilter, waitForCondition, + withTimeout, + TimeoutError, } from '../lib/asyncbox.js'; use(chaiAsPromised); @@ -20,6 +22,63 @@ describe('sleep', function () { }); }); +describe('withTimeout', function () { + function neverSettles(): Promise { + return new Promise(() => {}); + } + + it('should resolve when the promise settles before the deadline', async function () { + const result = await withTimeout(Promise.resolve(42), 1000); + expect(result).to.equal(42); + }); + it('should reject with TimeoutError when the deadline is exceeded', async function () { + await expect(withTimeout(neverSettles(), 30)).to.be.rejectedWith(TimeoutError); + }); + it('should use the default TimeoutError message when none is provided', async function () { + const timeoutMs = 20; + try { + await withTimeout(neverSettles(), timeoutMs); + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.be.instanceOf(TimeoutError); + expect((err as TimeoutError).message).to.equal(`Operation timed out after ${timeoutMs}ms`); + } + }); + it('should use a custom message on TimeoutError when provided', async function () { + try { + await withTimeout(neverSettles(), 20, 'custom timeout'); + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.be.instanceOf(TimeoutError); + expect((err as TimeoutError).message).to.equal('custom timeout'); + } + }); + it('should reject with a provided Error instance on timeout', async function () { + class CustomTimeout extends Error { + constructor(message?: string) { + super(message ?? 'custom default'); + this.name = 'CustomTimeout'; + } + } + const customErr = new CustomTimeout('overridden'); + try { + await withTimeout(neverSettles(), 20, customErr); + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.equal(customErr); + expect(err).to.be.instanceOf(CustomTimeout); + expect((err as CustomTimeout).message).to.equal('overridden'); + } + }); + it('should propagate rejection from the underlying promise before the deadline', async function () { + const failing = (async () => { + await sleep(10); + throw new Error('boom'); + })(); + await expect(withTimeout(failing, 1000)).to.be.rejectedWith('boom'); + }); +}); + describe('longSleep', function () { it('should work like sleep in general', async function () { const now = Date.now();