Skip to content

Commit

Permalink
Add timeout helper (#179)
Browse files Browse the repository at this point in the history
  • Loading branch information
sergiodxa committed Mar 31, 2023
1 parent db84058 commit 21e98b0
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 1 deletion.
87 changes: 87 additions & 0 deletions src/common/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,90 @@ export async function promiseHash<Hash extends PromiseHash>(
)
);
}

/**
* Used to uniquely identify a timeout
* @private
*/
let TIMEOUT = Symbol("TIMEOUT");

/**
* Attach a timeout to any promise, if the timeout resolves first ignore the
* original promise and throw an error
* @param promise The promise to attach a timeout to
* @param options The options to use
* @param options.ms The number of milliseconds to wait before timing out
* @param options.controller An AbortController to abort the original promise
* @returns The result of the promise
* @throws TimeoutError If the timeout resolves first
* @example
* try {
* let result = await timeout(
* fetch("https://example.com"),
* { ms: 100 }
* );
* } catch (error) {
* if (error instanceof TimeoutError) {
* // Handle timeout
* }
* }
* @example
* try {
* let controller = new AbortController();
* let result = await timeout(
* fetch("https://example.com", { signal: controller.signal }),
* { ms: 100, controller }
* );
* } catch (error) {
* if (error instanceof TimeoutError) {
* // Handle timeout
* }
* }
*/
export function timeout<Value>(
promise: Promise<Value>,
options: { controller?: AbortController; ms: number }
): Promise<Value> {
return new Promise(async (resolve, reject) => {
let timer: NodeJS.Timeout | null = null;

try {
let result = await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(TIMEOUT), options.ms);
}),
]);

if (timer) clearTimeout(timer);

if (result === TIMEOUT) {
if (options.controller) options.controller.abort();
return reject(new TimeoutError(`Timed out after ${options.ms}ms`));
}

return resolve(result as Awaited<Value>);
} catch (error) {
if (timer) clearTimeout(timer);
reject(error);
}
});
}

/**
* An error thrown when a timeout occurs
* @example
* try {
* let result = await timeout(fetch("https://example.com"), { ms: 100 });
* } catch (error) {
* if (error instanceof TimeoutError) {
* // Handle timeout
* }
* }
*/
export class TimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = "TimeoutError";
}
}
43 changes: 42 additions & 1 deletion test/common/promise.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { promiseHash } from "../../src";
import { promiseHash, timeout } from "../../src";

describe(promiseHash, () => {
test("should await all promises in a hash and return them with the same name", async () => {
Expand All @@ -23,3 +23,44 @@ describe(promiseHash, () => {
});
});
});

describe(timeout, () => {
test("resolves if the promise resolves before the timeout", async () => {
await expect(timeout(Promise.resolve(1), { ms: 1000 })).resolves.toBe(1);
});

test("rejects if the promise rejects before the timeout", async () => {
await expect(timeout(Promise.reject(1), { ms: 1000 })).rejects.toBe(1);
});

test("rejects if the timeout resolves first", async () => {
let timer: NodeJS.Timeout | null = null;

let promise = new Promise((resolve) => {
timer = setTimeout(resolve, 1000);
});

await expect(timeout(promise, { ms: 1 })).rejects.toThrow(
"Timed out after 1ms"
);

if (timer) clearTimeout(timer);
});

test("timeout aborts the controller", async () => {
let controller = new AbortController();
let timer: NodeJS.Timeout | null = null;

let promise = new Promise((resolve) => {
timer = setTimeout(resolve, 1000);
});

await expect(timeout(promise, { ms: 10, controller })).rejects.toThrow(
"Timed out after 10ms"
);

if (timer) clearTimeout(timer);

expect(controller.signal.aborted).toBe(true);
});
});

0 comments on commit 21e98b0

Please sign in to comment.