Skip to content

Commit

Permalink
BREAKING(async): simplify deadline() logic, remove DeadlineError
Browse files Browse the repository at this point in the history
…and improve errors (#5058)
  • Loading branch information
iuioiua authored Jun 21, 2024
1 parent 32d46e9 commit e561d36
Show file tree
Hide file tree
Showing 8 changed files with 163 additions and 132 deletions.
9 changes: 0 additions & 9 deletions async/_util.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.

// This `reason` comes from `AbortSignal` thus must be `any`.
// deno-lint-ignore no-explicit-any
export function createAbortError(reason?: any): DOMException {
return new DOMException(
reason ? `Aborted: ${reason}` : "Aborted",
"AbortError",
);
}

export function exponentialBackoffWithJitter(
cap: number,
base: number,
Expand Down
32 changes: 2 additions & 30 deletions async/_util_test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import { createAbortError, exponentialBackoffWithJitter } from "./_util.ts";
import { assertEquals, assertInstanceOf } from "@std/assert";
import { exponentialBackoffWithJitter } from "./_util.ts";
import { assertEquals } from "@std/assert";

// test util to ensure deterministic results during testing of backoff function by polyfilling Math.random
function prngMulberry32(seed: number) {
Expand Down Expand Up @@ -50,31 +50,3 @@ Deno.test("exponentialBackoffWithJitter()", () => {
assertEquals(results as typeof row, row);
}
});

Deno.test("createAbortError()", () => {
const error = createAbortError();
assertInstanceOf(error, DOMException);
assertEquals(error.name, "AbortError");
assertEquals(error.message, "Aborted");
});

Deno.test("createAbortError() handles aborted signal with reason", () => {
const c = new AbortController();
c.abort("Expected Reason");
const error = createAbortError(c.signal.reason);
assertInstanceOf(error, DOMException);
assertEquals(error.name, "AbortError");
assertEquals(error.message, "Aborted: Expected Reason");
});

Deno.test("createAbortError() handles aborted signal without reason", () => {
const c = new AbortController();
c.abort();
const error = createAbortError(c.signal.reason);
assertInstanceOf(error, DOMException);
assertEquals(error.name, "AbortError");
assertEquals(
error.message,
"Aborted: AbortError: The signal has been aborted",
);
});
116 changes: 80 additions & 36 deletions async/abortable.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,109 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.

import { createAbortError } from "./_util.ts";

/**
* Make a {@linkcode Promise} abortable with the given signal.
*
* @throws {DOMException} If the signal is already aborted and `signal.reason`
* is undefined. Otherwise, throws `signal.reason`.
* @typeParam T The type of the provided and returned promise.
* @param p The promise to make abortable.
* @param signal The signal to abort the promise with.
* @returns A promise that can be aborted.
*
* @example Usage
* ```ts no-eval
* import {
* abortable,
* delay,
* } from "@std/async";
* @example Error-handling a timeout
* ```ts
* import { abortable, delay } from "@std/async";
* import { assertRejects, assertEquals } from "@std/assert";
*
* const promise = delay(1_000);
*
* // Rejects with `DOMException` after 100 ms
* await assertRejects(
* () => abortable(promise, AbortSignal.timeout(100)),
* DOMException,
* "Signal timed out."
* );
* ```
*
* @example Error-handling an abort
* ```ts
* import { abortable, delay } from "@std/async";
* import { assertRejects, assertEquals } from "@std/assert";
*
* const p = delay(1000);
* const c = new AbortController();
* setTimeout(() => c.abort(), 100);
* const promise = delay(1_000);
* const controller = new AbortController();
* controller.abort(new Error("This is my reason"));
*
* // Below throws `DOMException` after 100 ms
* await abortable(p, c.signal);
* // Rejects with `DOMException` immediately
* await assertRejects(
* () => abortable(promise, controller.signal),
* Error,
* "This is my reason"
* );
* ```
*/
export function abortable<T>(p: Promise<T>, signal: AbortSignal): Promise<T>;
/**
* Make an {@linkcode AsyncIterable} abortable with the given signal.
*
* @throws {DOMException} If the signal is already aborted and `signal.reason`
* is undefined. Otherwise, throws `signal.reason`.
* @typeParam T The type of the provided and returned async iterable.
* @param p The async iterable to make abortable.
* @param signal The signal to abort the promise with.
* @returns An async iterable that can be aborted.
*
* @example Usage
* ```ts no-eval
* import {
* abortable,
* delay,
* } from "@std/async";
* @example Error-handling a timeout
* ```ts
* import { abortable, delay } from "@std/async";
* import { assertRejects, assertEquals } from "@std/assert";
*
* const p = async function* () {
* const asyncIter = async function* () {
* yield "Hello";
* await delay(1000);
* await delay(1_000);
* yield "World";
* };
* const c = new AbortController();
* setTimeout(() => c.abort(), 100);
*
* // Below throws `DOMException` after 100 ms
* // and items become `["Hello"]`
* const items: string[] = [];
* for await (const item of abortable(p(), c.signal)) {
* items.push(item);
* }
* // Below throws `DOMException` after 100 ms and items become `["Hello"]`
* await assertRejects(
* async () => {
* for await (const item of abortable(asyncIter(), AbortSignal.timeout(100))) {
* items.push(item);
* }
* },
* DOMException,
* "Signal timed out."
* );
* assertEquals(items, ["Hello"]);
* ```
*
* @example Error-handling an abort
* ```ts
* import { abortable, delay } from "@std/async";
* import { assertRejects, assertEquals } from "@std/assert";
*
* const asyncIter = async function* () {
* yield "Hello";
* await delay(1_000);
* yield "World";
* };
* const controller = new AbortController();
* controller.abort(new Error("This is my reason"));
*
* const items: string[] = [];
* // Below throws `DOMException` immediately
* await assertRejects(
* async () => {
* for await (const item of abortable(asyncIter(), controller.signal)) {
* items.push(item);
* }
* },
* Error,
* "This is my reason"
* );
* assertEquals(items, []);
* ```
*/
export function abortable<T>(
Expand All @@ -77,11 +125,9 @@ function abortablePromise<T>(
p: Promise<T>,
signal: AbortSignal,
): Promise<T> {
if (signal.aborted) {
return Promise.reject(createAbortError(signal.reason));
}
if (signal.aborted) return Promise.reject(signal.reason);
const { promise, reject } = Promise.withResolvers<never>();
const abort = () => reject(createAbortError(signal.reason));
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
return Promise.race([promise, p]).finally(() => {
signal.removeEventListener("abort", abort);
Expand All @@ -92,11 +138,9 @@ async function* abortableAsyncIterable<T>(
p: AsyncIterable<T>,
signal: AbortSignal,
): AsyncGenerator<T> {
if (signal.aborted) {
throw createAbortError(signal.reason);
}
signal.throwIfAborted();
const { promise, reject } = Promise.withResolvers<never>();
const abort = () => reject(createAbortError(signal.reason));
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });

const it = p[Symbol.asyncIterator]();
Expand Down
50 changes: 39 additions & 11 deletions async/abortable_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,24 @@ Deno.test("abortable() handles promise with aborted signal after delay", async (
const { promise, resolve } = Promise.withResolvers<string>();
const t = setTimeout(() => resolve("Hello"), 100);
setTimeout(() => c.abort(), 50);
await assertRejects(
async () => {
await abortable(promise, c.signal);
},
const error = await assertRejects(
() => abortable(promise, c.signal),
DOMException,
"AbortError",
"The signal has been aborted",
);
assertEquals(error.name, "AbortError");
clearTimeout(t);
});

Deno.test("abortable() handles promise with aborted signal after delay with reason", async () => {
const c = new AbortController();
const { promise, resolve } = Promise.withResolvers<string>();
const t = setTimeout(() => resolve("Hello"), 100);
setTimeout(() => c.abort(new Error("This is my reason")), 50);
await assertRejects(
() => abortable(promise, c.signal),
Error,
"This is my reason",
);
clearTimeout(t);
});
Expand All @@ -31,12 +43,26 @@ Deno.test("abortable() handles promise with already aborted signal", async () =>
const { promise, resolve } = Promise.withResolvers<string>();
const t = setTimeout(() => resolve("Hello"), 100);
c.abort();
await assertRejects(
const error = await assertRejects(
async () => {
await abortable(promise, c.signal);
},
DOMException,
"AbortError",
"The signal has been aborted",
);
assertEquals(error.name, "AbortError");
clearTimeout(t);
});

Deno.test("abortable() handles promise with already aborted signal with reason", async () => {
const c = new AbortController();
const { promise, resolve } = Promise.withResolvers<string>();
const t = setTimeout(() => resolve("Hello"), 100);
c.abort(new Error("This is my reason"));
await assertRejects(
() => abortable(promise, c.signal),
Error,
"This is my reason",
);
clearTimeout(t);
});
Expand Down Expand Up @@ -66,15 +92,16 @@ Deno.test("abortable.AsyncIterable() handles aborted signal after delay", async
};
setTimeout(() => c.abort(), 50);
const items: string[] = [];
await assertRejects(
const error = await assertRejects(
async () => {
for await (const item of abortable(a(), c.signal)) {
items.push(item);
}
},
DOMException,
"AbortError",
"The signal has been aborted",
);
assertEquals(error.name, "AbortError");
assertEquals(items, ["Hello"]);
clearTimeout(t);
});
Expand All @@ -90,15 +117,16 @@ Deno.test("abortable.AsyncIterable() handles already aborted signal", async () =
};
c.abort();
const items: string[] = [];
await assertRejects(
const error = await assertRejects(
async () => {
for await (const item of abortable(a(), c.signal)) {
items.push(item);
}
},
DOMException,
"AbortError",
"The signal has been aborted",
);
assertEquals(error.name, "AbortError");
assertEquals(items, []);
clearTimeout(t);
});
49 changes: 15 additions & 34 deletions async/deadline.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.

import { delay } from "./delay.ts";
// TODO(iuioiua): Add web-compatible declaration once TypeScript 5.5 is released
// and in the Deno runtime. See https://github.com/microsoft/TypeScript/pull/58211
//
// Note: this code is still compatible with recent
// web browsers. See https://caniuse.com/?search=AbortSignal.any
import { abortable } from "./abortable.ts";

/** Options for {@linkcode deadline}. */
export interface DeadlineOptions {
Expand All @@ -10,29 +13,14 @@ export interface DeadlineOptions {
}

/**
* Error thrown when {@linkcode deadline} times out.
*
* @example Usage
* ```ts no-assert
* import { DeadlineError } from "@std/async/deadline";
*
* const error = new DeadlineError();
* ```
*/
export class DeadlineError extends Error {
constructor() {
super("Deadline");
this.name = this.constructor.name;
}
}

/**
* Create a promise which will be rejected with {@linkcode DeadlineError} when
* Create a promise which will be rejected with {@linkcode DOMException} when
* a given delay is exceeded.
*
* Note: Prefer to use {@linkcode AbortSignal.timeout} instead for the APIs
* that accept {@linkcode AbortSignal}.
*
* @throws {DOMException} When the provided duration runs out before resolving
* or if the optional signal is aborted, and `signal.reason` is undefined.
* @typeParam T The type of the provided and returned promise.
* @param p The promise to make rejectable.
* @param ms Duration in milliseconds for when the promise should time out.
Expand All @@ -44,24 +32,17 @@ export class DeadlineError extends Error {
* import { deadline } from "@std/async/deadline";
* import { delay } from "@std/async/delay";
*
* const delayedPromise = delay(1000);
* // Below throws `DeadlineError` after 10 ms
* const delayedPromise = delay(1_000);
* // Below throws `DOMException` after 10 ms
* const result = await deadline(delayedPromise, 10);
* ```
*/
export function deadline<T>(
export async function deadline<T>(
p: Promise<T>,
ms: number,
options: DeadlineOptions = {},
): Promise<T> {
const controller = new AbortController();
const { signal } = options;
if (signal?.aborted) {
return Promise.reject(new DeadlineError());
}
signal?.addEventListener("abort", () => controller.abort(signal.reason));
const d = delay(ms, { signal: controller.signal })
.catch(() => {}) // Do NOTHING on abort.
.then(() => Promise.reject(new DeadlineError()));
return Promise.race([p.finally(() => controller.abort()), d]);
const signals = [AbortSignal.timeout(ms)];
if (options.signal) signals.push(options.signal);
return await abortable(p, AbortSignal.any(signals));
}
Loading

0 comments on commit e561d36

Please sign in to comment.