From a5c9703dfcad8763fcf20bc8a2648dd019a336c0 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 27 Jan 2026 09:26:11 +0100 Subject: [PATCH 1/3] feat(async/unstable): add poll function --- async/deno.json | 3 +- async/unstable_poll.ts | 89 +++++++++++++++++++++++++++ async/unstable_poll_test.ts | 119 ++++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 async/unstable_poll.ts create mode 100644 async/unstable_poll_test.ts diff --git a/async/deno.json b/async/deno.json index aa79a3c77de8..6d6465f709ee 100644 --- a/async/deno.json +++ b/async/deno.json @@ -16,6 +16,7 @@ "./unstable-throttle": "./unstable_throttle.ts", "./unstable-wait-for": "./unstable_wait_for.ts", "./unstable-semaphore": "./unstable_semaphore.ts", - "./unstable-circuit-breaker": "./unstable_circuit_breaker.ts" + "./unstable-circuit-breaker": "./unstable_circuit_breaker.ts", + "./unstable-poll": "./unstable_poll.ts" } } diff --git a/async/unstable_poll.ts b/async/unstable_poll.ts new file mode 100644 index 000000000000..a487086ed8bb --- /dev/null +++ b/async/unstable_poll.ts @@ -0,0 +1,89 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +import { delay } from "./delay.ts"; + +/** Options for {@linkcode poll}. */ +export interface PollOptions { + /** Signal used to abort the polling. */ + signal?: AbortSignal; + /** + * The interval in milliseconds between each poll. + * + * @default {1000} + */ + interval?: number; +} + +/** + * Repeatedly calls a function until a condition is met, then returns the result. + * + * This is useful for polling external APIs that don't provide push notifications. + * The function is called repeatedly with `interval` milliseconds between each call + * until `isDone` returns `true`, at which point the result is returned. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Polling a payment status with {@linkcode deadline} + * ```ts no-assert + * import { poll } from "@std/async/unstable-poll"; + * import { deadline } from "@std/async/deadline"; + * + * async function getPaymentStatus(id: string): Promise<{ status: string }> { + * // Fetch payment status from API + * return { status: "pending" }; + * } + * + * const result = await deadline( + * poll( + * () => getPaymentStatus("payment-123"), + * (payment) => payment.status !== "pending", + * { interval: 1000 }, + * ), + * 30_000, // 30 second timeout + * ); + * ``` + * + * @example Using AbortSignal for timeout + * ```ts no-assert + * import { poll } from "@std/async/unstable-poll"; + * + * const result = await poll( + * () => fetch("https://api.example.com/status").then((r) => r.json()), + * (data) => data.completed === true, + * { signal: AbortSignal.timeout(30_000) }, + * ); + * ``` + * + * @throws {DOMException & { name: "AbortError" }} If the optional signal is + * aborted with the default `reason`. + * @throws {AbortSignal["reason"]} If the optional signal is aborted with a + * custom `reason`. + * @throws If `fn` throws, that error propagates up immediately. + * @throws If `isDone` throws, that error propagates up immediately. + * @typeParam T The return type of the function being polled. + * @param fn The function to poll. Can be sync or async. + * @param isDone A predicate that returns `true` when polling should stop. + * @param options Additional options. + * @returns The result of `fn` when `isDone` returns `true`. + */ +export async function poll( + fn: () => T, + isDone: (result: Awaited) => boolean, + options: PollOptions = {}, +): Promise> { + const { signal, interval = 1000 } = options; + const delayOptions = signal ? { signal } : undefined; + + while (true) { + signal?.throwIfAborted(); + + const result = await fn(); + + if (isDone(result)) { + return result; + } + + await delay(interval, delayOptions); + } +} diff --git a/async/unstable_poll_test.ts b/async/unstable_poll_test.ts new file mode 100644 index 000000000000..9e0c1a94fe0e --- /dev/null +++ b/async/unstable_poll_test.ts @@ -0,0 +1,119 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertRejects } from "@std/assert"; +import { poll } from "./unstable_poll.ts"; +import { FakeTime } from "@std/testing/time"; + +Deno.test("poll() returns result when isDone returns true", async () => { + let callCount = 0; + const result = await poll( + () => { + callCount++; + return { status: callCount >= 3 ? "done" : "pending" }; + }, + (r) => r.status === "done", + { interval: 10 }, + ); + + assertEquals(result, { status: "done" }); + assertEquals(callCount, 3); +}); + +Deno.test("poll() returns immediately when isDone returns true on first call", async () => { + using time = new FakeTime(); + const startTime = time.now; + + const result = await poll( + () => "immediate", + () => true, + ); + + assertEquals(result, "immediate"); + assertEquals(time.now, startTime); +}); + +Deno.test("poll() works with async functions", async () => { + let callCount = 0; + const result = await poll( + async () => { + await Promise.resolve(); + return ++callCount; + }, + (n) => n >= 3, + { interval: 10 }, + ); + + assertEquals(result, 3); +}); + +Deno.test("poll() respects interval timing", async () => { + using time = new FakeTime(); + + let callCount = 0; + const promise = poll( + () => ++callCount, + (n) => n >= 4, + { interval: 200 }, + ); + + // First call is immediate + await time.runMicrotasks(); + assertEquals(callCount, 1); + + // Advance through remaining calls + await time.nextAsync(); + assertEquals(callCount, 2); + + await time.nextAsync(); + assertEquals(callCount, 3); + + await time.nextAsync(); + assertEquals(callCount, 4); + + await promise; +}); + +Deno.test("poll() handles abort signal", async () => { + const controller = new AbortController(); + let callCount = 0; + + const promise = poll( + () => ++callCount, + () => false, // Never done + { signal: controller.signal, interval: 10 }, + ); + + // Abort after a short delay + setTimeout(() => controller.abort(), 25); + + await assertRejects(() => promise, DOMException); + assertEquals(callCount, 3); +}); + +Deno.test("poll() handles already aborted signal", async () => { + const controller = new AbortController(); + controller.abort(); + + await assertRejects( + () => + poll( + () => "value", + () => false, + { signal: controller.signal }, + ), + DOMException, + ); +}); + +Deno.test("poll() propagates errors from fn", async () => { + await assertRejects( + () => + poll( + () => { + throw new Error("fetch failed"); + }, + () => true, + ), + Error, + "fetch failed", + ); +}); From 2b234d0c82bdf41e473f972b4432573fc3e68e40 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 27 Jan 2026 10:51:01 +0100 Subject: [PATCH 2/3] fix tests --- async/unstable_poll.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/async/unstable_poll.ts b/async/unstable_poll.ts index a487086ed8bb..e9a5e5452a3f 100644 --- a/async/unstable_poll.ts +++ b/async/unstable_poll.ts @@ -25,7 +25,7 @@ export interface PollOptions { * @experimental **UNSTABLE**: New API, yet to be vetted. * * @example Polling a payment status with {@linkcode deadline} - * ```ts no-assert + * ```ts ignore * import { poll } from "@std/async/unstable-poll"; * import { deadline } from "@std/async/deadline"; * @@ -45,7 +45,7 @@ export interface PollOptions { * ``` * * @example Using AbortSignal for timeout - * ```ts no-assert + * ```ts ignore * import { poll } from "@std/async/unstable-poll"; * * const result = await poll( From ee3d6a9622d70bf57199cf12f1810d9c194800bc Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 27 Jan 2026 12:23:14 +0100 Subject: [PATCH 3/3] fix flaky test --- async/unstable_poll_test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/async/unstable_poll_test.ts b/async/unstable_poll_test.ts index 9e0c1a94fe0e..f9ad9e0a5dea 100644 --- a/async/unstable_poll_test.ts +++ b/async/unstable_poll_test.ts @@ -73,6 +73,7 @@ Deno.test("poll() respects interval timing", async () => { }); Deno.test("poll() handles abort signal", async () => { + using time = new FakeTime(); const controller = new AbortController(); let callCount = 0; @@ -82,8 +83,19 @@ Deno.test("poll() handles abort signal", async () => { { signal: controller.signal, interval: 10 }, ); - // Abort after a short delay - setTimeout(() => controller.abort(), 25); + // First call is immediate + await time.runMicrotasks(); + assertEquals(callCount, 1); + + // Advance through two more intervals + await time.nextAsync(); + assertEquals(callCount, 2); + + await time.nextAsync(); + assertEquals(callCount, 3); + + // Abort during the delay after call 3 + controller.abort(); await assertRejects(() => promise, DOMException); assertEquals(callCount, 3);