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 18 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: 5 additions & 6 deletions async/_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
// 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(
// deno-lint-ignore no-explicit-any
reason: any = "The signal has been aborted",
): DOMException {
return new DOMException(reason, "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",
);
});
112 changes: 80 additions & 32 deletions async/abortable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,108 @@ 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 { assertRejects, 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);
* // Rejects with `DOMException` after 100 ms
* const error = await assertRejects(
* () => abortable(promise, AbortSignal.timeout(100)),
* DOMException,
* "TimeoutError: Signal timed out."
* );
* assertEquals(error.name, "AbortError");
* ```
*
* @example Error-handling an abort
* ```ts
* import { abortable, delay } from "@std/async";
* import { assertRejects, assertEquals } from "@std/assert";
*
* const promise = delay(1_000);
* const controller = new AbortController();
* controller.abort("This is my reason");
*
* // Rejects with `DOMException` immediately
* const error = await assertRejects(
* () => abortable(promise, controller.signal),
* DOMException,
* "This is my reason"
* );
* assertEquals(error.name, "AbortError");
* ```
*/
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 { assertRejects, assertEquals } from "@std/assert";
*
* const asyncIter = async function* () {
* yield "Hello";
* await delay(1_000);
* yield "World";
* };
*
* const items: string[] = [];
* // Below throws `DOMException` after 100 ms and items become `["Hello"]`
* const error = await assertRejects(
* async () => {
* for await (const item of abortable(asyncIter(), AbortSignal.timeout(100))) {
* items.push(item);
* }
* },
* DOMException,
* "TimeoutError: Signal timed out."
* );
* assertEquals(error.name, "AbortError");
* assertEquals(items, ["Hello"]);
* ```
*
* const p = async function* () {
* @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(1000);
* await delay(1_000);
* yield "World";
* };
* const c = new AbortController();
* setTimeout(() => c.abort(), 100);
* const controller = new AbortController();
* controller.abort("This is my reason");
*
* // 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` immediately
* const error = await assertRejects(
* async () => {
* for await (const item of abortable(asyncIter(), controller.signal)) {
* items.push(item);
* }
* },
* DOMException,
* "This is my reason"
* );
* assertEquals(error.name, "AbortError");
* assertEquals(items, []);
* ```
*/
export function abortable<T>(
Expand All @@ -77,9 +129,7 @@ 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(createAbortError(signal.reason));
const { promise, reject } = Promise.withResolvers<never>();
const abort = () => reject(createAbortError(signal.reason));
signal.addEventListener("abort", abort, { once: true });
Expand All @@ -92,9 +142,7 @@ async function* abortableAsyncIterable<T>(
p: AsyncIterable<T>,
signal: AbortSignal,
): AsyncGenerator<T> {
if (signal.aborted) {
throw createAbortError(signal.reason);
}
if (signal.aborted) throw createAbortError(signal.reason);
const { promise, reject } = Promise.withResolvers<never>();
const abort = () => reject(createAbortError(signal.reason));
signal.addEventListener("abort", abort, { once: true });
Expand Down
52 changes: 41 additions & 11 deletions async/abortable_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,26 @@ 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",
"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("This is my reason"), 50);
const error = await assertRejects(
() => abortable(promise, c.signal),
DOMException,
"This is my reason",
);
assertEquals(error.name, "AbortError");
clearTimeout(t);
});

Expand All @@ -31,13 +44,28 @@ 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("This is my reason");
const error = await assertRejects(
() => abortable(promise, c.signal),
DOMException,
"This is my reason",
);
assertEquals(error.name, "AbortError");
clearTimeout(t);
});

Expand Down Expand Up @@ -66,15 +94,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 +119,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