Skip to content
Merged
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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
50 changes: 50 additions & 0 deletions lib/asyncbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,6 +19,48 @@ export async function sleep(ms: number): Promise<void> {
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<T>(
promise: Promise<T>,
timeoutMs: number,
messageOrError?: string | Error,
Comment thread
mykola-mokhnach marked this conversation as resolved.
): Promise<T> {
let timer: NodeJS.Timeout | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we should guard this branch against non-Error types

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for now I would keep it as is. Not sure if it ever would become an issue

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
Expand Down
59 changes: 59 additions & 0 deletions test/asyncbox-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
asyncmap,
asyncfilter,
waitForCondition,
withTimeout,
TimeoutError,
} from '../lib/asyncbox.js';

use(chaiAsPromised);
Expand All @@ -20,6 +22,63 @@ describe('sleep', function () {
});
});

describe('withTimeout', function () {
function neverSettles<T>(): Promise<T> {
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<string>(), 30)).to.be.rejectedWith(TimeoutError);
});
it('should use the default TimeoutError message when none is provided', async function () {
const timeoutMs = 20;
Comment thread
mykola-mokhnach marked this conversation as resolved.
try {
await withTimeout(neverSettles<string>(), 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<string>(), 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<string>(), 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();
Expand Down
Loading