Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BREAKING(async): simplify deadline() logic, remove DeadlineError and improve errors #5058

Merged
merged 22 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
11 changes: 4 additions & 7 deletions async/_util.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
// 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 createAbortError(
message = "The signal has been aborted",
): DOMException {
return new DOMException(message, "AbortError");
}

export function exponentialBackoffWithJitter(
Expand Down
6 changes: 3 additions & 3 deletions async/_util_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Deno.test("createAbortError()", () => {
const error = createAbortError();
assertInstanceOf(error, DOMException);
assertEquals(error.name, "AbortError");
assertEquals(error.message, "Aborted");
assertEquals(error.message, "The signal has been aborted");
});

Deno.test("createAbortError() handles aborted signal with reason", () => {
Expand All @@ -64,7 +64,7 @@ Deno.test("createAbortError() handles aborted signal with reason", () => {
const error = createAbortError(c.signal.reason);
assertInstanceOf(error, DOMException);
assertEquals(error.name, "AbortError");
assertEquals(error.message, "Aborted: Expected Reason");
assertEquals(error.message, "Expected Reason");
});

Deno.test("createAbortError() handles aborted signal without reason", () => {
Expand All @@ -75,6 +75,6 @@ Deno.test("createAbortError() handles aborted signal without reason", () => {
assertEquals(error.name, "AbortError");
assertEquals(
error.message,
"Aborted: AbortError: The signal has been aborted",
"AbortError: The signal has been aborted",
);
});
110 changes: 78 additions & 32 deletions async/abortable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,105 @@ import { createAbortError } from "./_util.ts";
/**
* Make a {@linkcode Promise} abortable with the given signal.
*
* @throws {DOMException} If the signal is already aborted.
* @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 { assertInstanceOf, assertEquals } from "@std/assert";
*
* const p = delay(1000);
* const c = new AbortController();
* setTimeout(() => c.abort(), 100);
* const promise = delay(1_000);
*
* // Below throws `DOMException` after 100 ms
* await abortable(p, c.signal);
* try {
* // Rejects with `DOMException` after 100 ms
* await abortable(promise, AbortSignal.timeout(100));
* } catch (error) {
* assertInstanceOf(error, DOMException);
* assertEquals(error.name, "AbortError");
* assertEquals(error.message, "TimeoutError: Signal timed out.");
* }
* ```
*
* @example Error-handling an abort
* ```ts
* import { abortable, delay } from "@std/async";
* import { assertInstanceOf, assertEquals } from "@std/assert";
*
* const promise = delay(1_000);
* const controller = new AbortController();
* controller.abort();
*
* try {
* // Rejects with `DOMException` immediately
* await abortable(promise, controller.signal);
* } catch (error) {
* assertInstanceOf(error, DOMException);
* assertEquals(error.name, "AbortError");
* assertEquals(error.message, "The signal has been aborted");
* }
* ```
*/
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.
* @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 { assertInstanceOf, assertEquals } from "@std/assert";
*
* const asyncIter = async function* () {
* yield "Hello";
* await delay(1_000);
* yield "World";
* };
*
* try {
* // Below throws `DOMException` after 100 ms and items become `["Hello"]`
* const items = [];
* for await (const item of abortable(asyncIter(), AbortSignal.timeout(100))) {
* items.push(item);
* }
* } catch (error) {
* assertInstanceOf(error, DOMException);
* assertEquals(error.name, "AbortError");
* assertEquals(error.message, "TimeoutError: Signal timed out.");
* }
* ```
*
* @example Error-handling an abort
* ```ts
* import { abortable, delay } from "@std/async";
* import { assertInstanceOf, 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);
* const controller = new AbortController();
* controller.abort();
*
* // 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);
* try {
* // Below throws `DOMException` immediately
* const items = [];
* for await (const item of abortable(asyncIter(), controller.signal)) {
* items.push(item);
* }
* } catch (error) {
* assertInstanceOf(error, DOMException);
* assertEquals(error.name, "AbortError");
* assertEquals(error.message, "The signal has been aborted");
* }
* ```
*/
Expand All @@ -77,9 +127,7 @@ function abortablePromise<T>(
p: Promise<T>,
signal: AbortSignal,
): Promise<T> {
if (signal.aborted) {
return Promise.reject(createAbortError(signal.reason));
}
signal.throwIfAborted();
const { promise, reject } = Promise.withResolvers<never>();
const abort = () => reject(createAbortError(signal.reason));
signal.addEventListener("abort", abort, { once: true });
Expand All @@ -92,9 +140,7 @@ 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));
signal.addEventListener("abort", abort, { once: true });
Expand Down
20 changes: 12 additions & 8 deletions async/abortable_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ 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(
const error = await assertRejects(
async () => {
await abortable(promise, c.signal);
},
DOMException,
"AbortError",
"AbortError: The signal has been aborted",
);
assertEquals(error.name, "AbortError");
clearTimeout(t);
});

Expand All @@ -31,13 +32,14 @@ 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);
});

Expand Down Expand Up @@ -66,15 +68,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",
"AbortError: The signal has been aborted",
);
assertEquals(error.name, "AbortError");
assertEquals(items, ["Hello"]);
clearTimeout(t);
});
Expand All @@ -90,15 +93,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.
* @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