Repository: true-myth/true-myth
Pull request #937 (2025-01-24 17:22:35)

Task: add a new `withRetries` function
This new utility function, modeled fairly directly on the [the Rust `retry` crate][crate], lets you retry a `Task` if it initially rejects.

Although you *can* customize nearly every part of this, you do not have to. By default, `withRetries` will immediately retry the callback up to three times:

```ts
let fetchTask = Task.withRetries(
  () => Task.fromPromise(fetch('https://true-myth.js.org'))
);
```

When combined with tools like `Task.safe`, this can becomes a *very* concise abstraction:

```ts
const fetch = Task.safe(window.fetch);
let result = await Task.withRetries(() => fetch('https://true-myth.js.org'))
```

You can fully customize its behavior, however:

1. The retryable callback receives a status object which reports the number of retries and the elapsed time requested. You can use that to determine what actions to take depending on how long you have been trying an operation.

    > [!NOTE]
    > The `elapsed` value will always be greater than or equal to the requested elapsed time after the first try, because even calling `setTimeout(() => {}, 0)` will take at least one microtask queue tick, and JavaScript runtimes do not guarantee *exactly* the time it takes for promises to settle or `setTimeout` to resolve, and the resolution changes over time.
 
2. You can by supplying a `Strategy` which is simply an iterator which produces a number of milliseconds to wait to try again. When combined with iterator helpers (either manually authored via generator functions or via the new iterator built-ins in ES2025), this makes for a very straightforward way to build up custom retry behaviors.n

3. You can stop retrying at any time, using the `stopRetrying()` function, which accepts a message describing why and an optional cause.

This example show roughly the full API available, using the `exponential` delay strategy supplied by the library (there other supplied strategies are `fibonacci`, `fixed`, `immediate`, `linear`, and `none`):

```ts
import * as Task from 'true-myth/task';
import * as Delay from 'true-myth/task/delay';

let fetchTask = Task.withRetries(
  ({ count, elapsed }) => {
    if (elapsed > 100_000) {
      return Task.stopRetrying(`Went too long: ${elapsed}ms`);
    }

    return Task.fromPromise(fetch('https://true-myth.js.org'))
      .orElse((rejection) => {
        let wrapped = new Error(
          `fetch has rejected ${count} times`,
          { cause: rejection }
        );
        return Task.reject(wrapped);
      });
  },
  Delay.exponential({ from: 10, factor: 3 }).map(Delay.jitter).take(10),
);
```

All of the built-in retry delay strategies (`exponential`, `fibonacci`, `fixed`, `immediate`, `linear`, and `none`) have good defaults such that you can simply call them like `fibonacci()`.

> [!NOTE]
> This same use of iterators works even in versions of TS and runtimes prior to the stabilization of the Iterator Helpers proposal in ES2025, using ES6 generators for `take`, `map`, etc.

[crate]: https://docs.rs/retry/latest/retry/


Patch:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 275c7f6..0c4113c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,10 +13,10 @@ importers:
         version: 7.0.0(release-it@17.11.0(typescript@5.3.3))
       '@types/node':
         specifier: ^22.0.0
-        version: 22.10.10
+        version: 22.10.7
       '@vitest/coverage-v8':
         specifier: ^2.0.1
-        version: 2.0.5(vitest@2.0.5(@types/node@22.10.10))
+        version: 2.0.5(vitest@2.0.5(@types/node@22.10.7))
       miniglob:
         specifier: ^0.1.2
         version: 0.1.2
@@ -40,7 +40,7 @@ importers:
         version: 5.3.3
       vitest:
         specifier: ^2.0.1
-        version: 2.0.5(@types/node@22.10.10)
+        version: 2.0.5(@types/node@22.10.7)
 
 packages:
 
@@ -462,8 +462,8 @@ packages:
   '@types/ms@0.7.34':
     resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
 
-  '@types/node@22.10.10':
-    resolution: {integrity: sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==}
+  '@types/node@22.10.7':
+    resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==}
 
   '@types/unist@2.0.10':
     resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@@ -2503,13 +2503,13 @@ snapshots:
 
   '@types/ms@0.7.34': {}
 
-  '@types/node@22.10.10':
+  '@types/node@22.10.7':
     dependencies:
       undici-types: 6.20.0
 
   '@types/unist@2.0.10': {}
 
-  '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@22.10.10))':
+  '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@22.10.7))':
     dependencies:
       '@ampproject/remapping': 2.3.0
       '@bcoe/v8-coverage': 0.2.3
@@ -2523,7 +2523,7 @@ snapshots:
       std-env: 3.7.0
       test-exclude: 7.0.1
       tinyrainbow: 1.2.0
-      vitest: 2.0.5(@types/node@22.10.10)
+      vitest: 2.0.5(@types/node@22.10.7)
     transitivePeerDependencies:
       - supports-color
 
@@ -4239,13 +4239,13 @@ snapshots:
       resolve-package-path: 4.0.3
       semver: 7.6.3
 
-  vite-node@2.0.5(@types/node@22.10.10):
+  vite-node@2.0.5(@types/node@22.10.7):
     dependencies:
       cac: 6.7.14
       debug: 4.3.5
       pathe: 1.1.2
       tinyrainbow: 1.2.0
-      vite: 5.4.8(@types/node@22.10.10)
+      vite: 5.4.8(@types/node@22.10.7)
     transitivePeerDependencies:
       - '@types/node'
       - less
@@ -4257,16 +4257,16 @@ snapshots:
       - supports-color
       - terser
 
-  vite@5.4.8(@types/node@22.10.10):
+  vite@5.4.8(@types/node@22.10.7):
     dependencies:
       esbuild: 0.21.5
       postcss: 8.4.47
       rollup: 4.24.0
     optionalDependencies:
-      '@types/node': 22.10.10
+      '@types/node': 22.10.7
       fsevents: 2.3.3
 
-  vitest@2.0.5(@types/node@22.10.10):
+  vitest@2.0.5(@types/node@22.10.7):
     dependencies:
       '@ampproject/remapping': 2.3.0
       '@vitest/expect': 2.0.5
@@ -4284,11 +4284,11 @@ snapshots:
       tinybench: 2.8.0
       tinypool: 1.0.0
       tinyrainbow: 1.2.0
-      vite: 5.4.8(@types/node@22.10.10)
-      vite-node: 2.0.5(@types/node@22.10.10)
+      vite: 5.4.8(@types/node@22.10.7)
+      vite-node: 2.0.5(@types/node@22.10.7)
       why-is-node-running: 2.3.0
     optionalDependencies:
-      '@types/node': 22.10.10
+      '@types/node': 22.10.7
     transitivePeerDependencies:
       - less
       - lightningcss
diff --git a/src/doc/delay.md b/src/doc/delay.md
new file mode 100644
index 0000000..8326f2c
--- /dev/null
+++ b/src/doc/delay.md
@@ -0,0 +1,72 @@
+# Task > Delay
+
+Types and helpers for managing retry delays with the `withRetries` helper from `Task`.
+
+The `withRetries` function accepts two arguments:
+
+- A callback that produces a `Task` or `StopRetrying`, an `Error` subclass that acts as a “sentinel” value for.
+- A retry delay strategy, which is an iterable iterator of delay times.
+
+When the callback produces a resolved `Task`, the `Task` returned from `withRetries` resolves to that value and all previous rejections are dropped. When the callback produces a rejected `Task`, `withRetries` retries the callback using the delay until the retry strategy is exhausted, at which point `withRetries` will produce a `Task` that rejects with a `RetryFailure`, which will have all rejection values.
+
+The strategies provided in this module represent the *most common*, but definitely not the *only possible* strategies you can use for retrying.
+
+- `exponential`
+- `fibonacci`
+- `fixed`
+- `immediate`
+- `linear`
+- `none`
+
+Additionally, the `jitter` function provides a useful tool for generating random variations on a retry strategy, to help avoid [“thundering herd” problems][thp] where many tasks kick off at the same time, fail at the same time, and then retry at the same time, causing increasing load on a resource that is already failing.
+
+[thp]: https://en.wikipedia.org/wiki/Thundering_herd_problem
+
+You should make sure you understand the tradeoffs of each backoff strategy.
+
+> [!NOTE]
+> All the helpers in this module except `none` are infinite, so you almost certainly want to use another helper to stop after a number of retries or to use the `count` passed as an argument to do the same.
+>
+> **Recommended:** Update to at least TS 5.6+, or use a TypeScript-aware polyfill for the Iterator Helpers feature (ES2025). In that case, you can simply use the `take` method directly:
+>
+> ```ts
+> import * as Task from 'true-myth/task';
+> import * as Delay from 'true-myth/task/delay';
+>
+> let theTask = Task.withRetries(
+>   () => Task.fromPromise(fetch('https://example.com/')),
+>   Delay.exponential().map(Delay.jitter).take(5),
+> );
+> ```
+>
+> **Fallback:** If you are unable to use a polyfill or upgrade to TS 5.6 for now, you can still use these safely using generator functions, which are long-standing JavaScript features available in all modern browsers since ES6. The above example might be written like this (note that these are fully-general versions of the `take` and `map` functions—that is, much more general than is required for working with the “strategies” from True Myth).
+>
+> ```ts
+> import * as Task from 'true-myth/task';
+> import { exponential, jitter } from 'true-myth/task/delay';
+>
+> let theTask = Task.withRetries(
+>   () => Task.fromPromise(fetch('https://example.com/')),
+>   take(map(Delay.exponential(), Delay.jitter), 5),
+> );
+>
+> function* take<T>(iterable: Iterable<T>, count: number): Generator<T> {
+>   let taken = 0;
+>   for (let item of iterable) {
+>     if (taken >= count) {
+>       break;
+>     }
+>
+>     taken += 1;
+>     yield item;
+>   }
+> }
+>
+> function* map<T, U>(iterable: Iterable<T>, fn: (t: T) => U): Generator<U> {
+>   for (let value of iterable) {
+>     yield fn(value);
+>   }
+> }
+> ```
+>
+> This is a bit harder to follow but works the same way, and will let you migrate incrementally once you are able to use the ES2025 Iterator Helpers features.
diff --git a/src/task.ts b/src/task.ts
index 0b00bcf..e61fe4d 100644
--- a/src/task.ts
+++ b/src/task.ts
@@ -8,6 +8,11 @@ import { curry1, safeToString } from './-private/utils.js';
 import Maybe from './maybe.js';
 import Result, { map as mapResult, mapErr, match as matchResult } from './result.js';
 import Unit from './unit.js';
+import * as Delay from './task/delay.js';
+
+// Make the Delay namespace available as `Task.Delay` for convenience. This lets
+// people do `Task.withRetries(aTask, Task.Delay.exponential(1_000).take(10))`.
+export { Delay };
 
 /**
   Internal implementation details for {@linkcode Task}.
@@ -1079,6 +1084,8 @@ export type Matcher<T, E, A> = {
   The error thrown when an error is thrown in the executor passed to {@linkcode
   Task.constructor}. This error class exists so it is clear exactly what went
   wrong in that case.
+
+  @group Errors
  */
 export class TaskExecutorException extends Error {
   name = 'TrueMyth.Task.ThrowingExecutor';
@@ -1096,7 +1103,9 @@ export class TaskExecutorException extends Error {
 
 /**
   An error thrown when the `Promise<Result<T, E>>` passed to
-  {@link Task.fromUnsafePromise} rejects.
+  {@link fromUnsafePromise} rejects.
+
+  @group Errors
 */
 export class UnsafePromise extends Error {
   readonly name = 'TrueMyth.Task.UnsafePromise';
@@ -2034,3 +2043,365 @@ export function toPromise<T, E>(task: Task<T, E>) {
 function identity<T>(value: T): T {
   return value;
 }
+
+/**
+  Execute a callback that produces either a {@linkcode Task} or the “sentinel”
+  [`Error`][error-mdn] subclass {@linkcode StopRetrying}. `withRetries` retries
+  the `retryable` callback until the retry strategy is exhausted *or* until the
+  callback returns either `StopRetrying` or a `Task` that rejects with
+  `StopRetrying`. If no strategy is supplied, a default strategy of retrying
+  immediately up to three times is used.
+
+  [error-mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
+
+  The `strategy` is any iterable iterator that produces an integral number,
+  which is used as the number of milliseconds to delay before retrying the
+  `retryable`. When the `strategy` stops yielding values, this will produce a
+  {@linkcode Rejected} `Task` whose rejection value is an instance of
+  {@linkcode RetryFailed}.
+
+  Returning `stopRetrying()` from the top-level of the function or as the
+  rejection reason will also produce a rejected `Task` whose rejection value is
+  an instance of `RetryFailed`, but will also immediately stop all further
+  retries and will include the `StopRetrying` instance as the `cause` of the
+  `RetryFailed` instance.
+
+  You can determine whether retries stopped because the strategy was exhausted
+  or because `stopRetrying` was called by checking the `cause` on the
+  `RetryFailed` instance. It will be `undefined` if the the `RetryFailed` was
+  the result of the strategy being exhausted. It will be a `StopRetrying` if it
+  stopped because the caller returned `stopRetrying()`.
+
+  ## Examples
+
+  ### Retrying with backoff
+
+  When attempting to fetch data from a server, you might want to retry if and
+  only if the response was an HTTP 408 response, indicating that there was a
+  timeout but that the client is allowed to try again. For other error codes, it
+  will simply reject immediately.
+
+  ```ts
+  import * as Task from 'true-myth/task';
+  import * as Delay from 'true-myth/task/delay';
+
+  let theTask = withRetries(
+    () => Task.fromPromise(fetch('https://example.com')).andThen((res) => {
+        if (res.status === 200) {
+          return Task.fromPromise(res.json());
+        } else if (res.status === 408) {
+          return Task.reject(res.statusText);
+        } else {
+          return Task.stopRetrying(res.statusText);
+        }
+      }),
+    Delay.fibonacci().map(Delay.jitter).take(10)
+  );
+  ```
+
+  Here, this uses a Fibonacci backoff strategy, which can be preferable in some
+  cases to a classic exponential backoff strategy (see [A Performance Comparison
+  of Different Backoff Algorithms under Different Rebroadcast Probabilities for
+  MANET's][pdf] for more details).
+
+  [pdf]: https://www.researchgate.net/publication/255672213_A_Performance_Comparison_of_Different_Backoff_Algorithms_under_Different_Rebroadcast_Probabilities_for_MANET's
+
+  ### Manually canceling retries
+
+  Sometimes, you may determine that the result of an operation is fatal, so
+  there is no point in retrying even if the retry strategy still allows it. In
+  that case, you can return the special `StopRetrying` error produced by calling
+  `stopRetrying` to immediately stop all further retries.
+
+  For example, imagine you have a library function that returns a custom `Error`
+  subclass that includes an `isFatal` value on it, something like this::
+
+  ```ts
+  class AppError extends Error {
+    isFatal: boolean;
+    constructor(message: string, options?: { isFatal?: boolean, cause?: unknown }) {
+      super(message, { cause: options?.cause });
+      this.isFatal = options?.isFatal ?? false;
+    }
+  }
+  ```
+
+  You could check that flag in a `Task` rejection and return `stopRetrying()` if
+  it is set:
+
+  ```ts
+  import * as Task from 'true-myth/task';
+  import { fibonacci, jitter } from 'true-myth/task/delay';
+  import { doSomethingThatMayFailWithAppError } from 'someplace/in/my-app';
+
+  let theTask = Task.withRetries(
+    () => {
+      doSomethingThatMayFailWithAppError().orElse((rejection) => {
+        if (rejection.isFatal) {
+          return Task.stopRetrying("It was fatal!", { cause: rejection });
+        }
+
+        return Task.reject(rejection);
+      });
+    },
+    fibonacci().map(jitter).take(20)
+  );
+  ```
+
+  ### Using the retry `status` parameter
+
+  Every time `withRetries` tries the `retryable`, it provides the current count
+  of attempts and the total elapsed duration as properties on the `status`
+  object, so you can do different things for a given way of trying the async
+  operation represented by the `Task` depending on the count. Here, for example,
+  the task is retried if the HTTP request rejects, with an exponential backoff
+  starting at 100 milliseconds, and captures the number of retries in an `Error`
+  wrapping the rejection reason when the response rejects or when converting the
+  response to JSON fails. It also stops if it has tried the call more than 10
+  times or if the total elapsed time exceeds 10 seconds.
+
+  ```ts
+  import * as Task from 'true-myth/task';
+  import { exponential, jitter } from 'true-myth/task/delay';
+
+  let theResult = await Task.withRetries(
+    ({ count, elapsed }) => {
+      if (count > 10) {
+        return Task.stopRetrying(`Tried too many times: ${count}`);
+      }
+
+      if (elapsed > 10_000) {
+        return Task.stopRetrying(`Took too long: ${elapsed}ms`);
+      }
+
+      return Task.fromPromise(fetch('https://www.example.com/'))
+        .andThen((res) => Task.fromPromise(res.json()))
+        .orElse((cause) => {
+          let message = `Attempt #${count} failed`;
+          return Task.reject(new Error(message, { cause }));
+        });
+    },
+    exponential().map(jitter),
+  );
+  ```
+
+  ### Custom strategies
+
+  While the {@link task/delay} module supplies a number of useful strategies,
+  you can also supply your own. The easiest way is to write [a generator
+  function][gen], but you can also implement a custom iterable iterator,
+  including by providing a subclass of the ES2025 `Iterator` class.
+
+  Here is an example of using a generator function to produce a random but
+  [monotonically increasing][monotonic] value proportional to the current
+  value:
+
+  ```ts
+  import * as Task from 'true-myth/task';
+
+  function* randomIncrease(options?: { from: number }) {
+    // always use integral values, and default to one second.
+    let value = options ? Math.round(options.from) : 1_000;
+    while (true) {
+      yield value;
+      value += Math.ceil(Math.random() * value); // always increase!
+    }
+  }
+
+  await Task.withRetries(({ count }) => {
+    let delay = Math.round(Math.random() * 100);
+    return Task.timer(delay).andThen((time) =>
+      Task.reject(`Rejection #${count} after ${time}ms`),
+    );
+  }, randomIncrease(10).take(10));
+  ```
+
+  [monotonic]: https://en.wikipedia.org/wiki/Monotonic_function
+
+  @param retryable A callback that produces a {@linkcode Task Task<T, E>}.
+  @param strategy An iterable iterator that produces an integral number of
+    milliseconds to wait before trying `retryable` again. If not supplied, the
+    `retryable` will be retried immediately up to three times.
+
+  @template T The type of a {@linkcode Resolved} {@linkcode Task}.
+  @template E The type of a {@linkcode Rejected} {@linkcode Task}.
+ */
+export function withRetries<T, E>(
+  retryable: (status: RetryStatus) => Task<T, E | StopRetrying> | StopRetrying,
+  strategy: IterableIterator<number> = (function* () {
+    for (let i = 0; i < 3; i++) {
+      yield 0;
+    }
+  })()
+): Task<T, RetryFailed<E>> {
+  const startTime = Date.now();
+
+  let count = 0;
+  let totalDuration = 0;
+  let rejections = new Array<E>();
+
+  /**
+    Internal helper so the operation can recurse. Note: if you have too large a
+    retry count, this *can* blow the stack; there is no trampolining here.
+    However, that would be extremely unlikely in most backoff scenarios, where
+    you usually take a set number of retries and/or take so long *between*
+    retries.
+   */
+  function helper(): Task<T, RetryFailed<E>> {
+    // Try it!
+    let taskOrErr = retryable({ count, elapsed: totalDuration });
+
+    if (taskOrErr instanceof Error) {
+      return Task.reject(
+        new RetryFailed({ tries: count, totalDuration, rejections, cause: taskOrErr })
+      );
+    }
+
+    // On `Task` rejection, capture the rejection, then check whether the task
+    // can be retried:
+    //
+    // - If the `Task` rejected specifically with `StopRetrying`… stop retrying.
+    // - If there are no more retries available, reject with `RetryFailed`.
+    // - If there are still retries available, execute the retryable on the next
+    //   delay, keeping track of how many retries have been attempted and the
+    //   cumulative delay of those retries.
+    return taskOrErr.orElse((reason) => {
+      if (reason instanceof StopRetrying) {
+        return Task.reject(
+          new RetryFailed({ tries: count, totalDuration, rejections, cause: reason })
+        );
+      }
+
+      rejections.push(reason);
+
+      let next = strategy.next();
+      if (next.done) {
+        return Task.reject(new RetryFailed({ tries: count, totalDuration, rejections }));
+      }
+
+      let delay = next.value;
+      totalDuration += Date.now() - startTime;
+      count += 1;
+
+      return timer(delay).andThen(helper);
+    });
+  }
+
+  return helper();
+}
+
+/** Information about the current retryable call status. */
+export interface RetryStatus {
+  /** The 0-indexed number of times the retryable has been called. */
+  count: number;
+  /** The total duration that has elapsed across calls. Initially 0. */
+  elapsed: number;
+}
+
+/**
+  A custom [`Error`][mdn-error] subclass which acts as a “sentinel”: when you
+  return it either as the top-level return value from the callback for
+  {@linkcode withRetries} or the rejection reason for a {@linkcode Task}
+  produces by `withRetries`, the function will stop retrying immediately.
+
+  [mdn-error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
+
+  You can neither construct this class directly nor subclass it. Instead, use
+  the {@linkcode stopRetrying} helper function to construct it.
+
+  @group Errors
+ */
+class StopRetrying extends Error {
+  get name(): string {
+    return 'TrueMyth.Task.StopRetrying';
+  }
+}
+
+export type { StopRetrying };
+
+/**
+  Produces the “sentinel” `Error` subclass {@linkcode StopRetrying}, for use as
+  a return value from {@linkcode withRetries}.
+
+  @param message The message to attach to the {@linkcode StopRetrying} instance.
+  @param cause The previous cause (often another `Error`) that resulted in
+    stopping retries.
+ */
+export function stopRetrying(message: string, cause?: unknown): StopRetrying {
+  return new StopRetrying(message, { cause });
+}
+
+export const RETRY_FAILED_NAME = 'TrueMyth.Task.RetryFailed';
+
+/**
+  An [`Error`][mdn-error] subclass for when a `Task` rejected after a specified
+  number of retries. It includes all rejection reasons, including the final one,
+  as well as the number of retries and the total duration spent on the retries.
+  It distinguishes between the list of rejections and the optional `cause`
+  property inherited from `Error` so that it can indicate if the retries failed
+  because the retry strategy was exhausted (in which case `cause` will be
+  `undefined`) or because the caller returned a {@linkcode StopRetrying}
+  instance (in which case `cause` will be that instance.)
+
+  You can neither construct nor subclass this error, only use its properties. If
+  you need to check whether an `Error` class is an instance of this class, you
+  can check whether its `name` is {@linkcode RETRY_FAILED_NAME} or you can use
+  the {@linkcode isRetryFailed} helper function:
+
+  ```ts
+  import * as Task from 'true-myth/task';
+
+  // snip
+  let result = await someFnThatReturnsATask();
+  if (result.isErr) {
+    if (isRetryFailed(result.error)) {
+      if (result.error.cause) {
+        console.error('You quit on purpose: ', cause);
+      }
+
+      for (let rejection of result.error.rejections) {
+        console.error(rejection);
+       }
+    } else {
+      // handle other error types
+    }
+  }
+  ```
+
+  [mdn-error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
+
+  @group Errors
+*/
+class RetryFailed<E> extends Error {
+  get name(): typeof RETRY_FAILED_NAME {
+    return RETRY_FAILED_NAME;
+  }
+
+  readonly tries: number;
+  readonly totalDuration: number;
+  readonly rejections: E[];
+
+  /** @internal */
+  constructor({
+    tries,
+    totalDuration,
+    rejections,
+    cause,
+  }: {
+    tries: number;
+    totalDuration: number;
+    rejections: E[];
+    cause?: Error;
+  }) {
+    super(`Stopped retrying after ${tries} tries (${totalDuration}ms)`, { cause });
+    this.rejections = rejections;
+    this.tries = tries;
+    this.totalDuration = totalDuration;
+  }
+}
+
+export function isRetryFailed(error: unknown): error is RetryFailed<unknown> {
+  return error instanceof Error && error.name === RETRY_FAILED_NAME;
+}
+
+export type { RetryFailed };
diff --git a/src/task/delay.ts b/src/task/delay.ts
new file mode 100644
index 0000000..d6a93bc
--- /dev/null
+++ b/src/task/delay.ts
@@ -0,0 +1,239 @@
+/**
+  {@include ../doc/delay.md}
+
+  @module
+ */
+
+/**
+  A `Strategy` is any iterable iterator which yields numbers. You can implement
+  it using the `IterableIterator` interface, i.e. `implements Strategy`, or you
+  can write a generator function which produces `Generator<number>`.
+
+  ## Examples
+
+  You can define your own generator functions or iterable iterators and pass
+  them as the strategy for the delay, or you can implement a class which
+  implements this interface. If you are able to target ES2025 (including by
+  using a polyfill), you can also provide subclasses of `Iterator`.
+
+  ```ts
+  function* randomInRange(min: number, max: number): Strategy<number> {
+    while (true) {
+      let scaled = Math.random() * (max - min + 1);
+      let scaledInt = Math.floor(scaled);
+      let startingAtMin = scaledInt + min;
+      yield startingAtMin
+    }
+  }
+
+  //
+  class RandomInteger implements Strategy {
+    #nextValue: number;
+
+    constructor(initial: number) {
+      this.#nextValue = initial;
+    }
+
+    next(): IteratorResult<number, void> {
+      this.#nextValue = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
+      return { done: false, value: this.#nextValue };
+    }
+
+    return(value: number): IteratorResult<number, void> {
+      return { done: false, value };
+    }
+
+    throw(_error: unknown): IteratorResult<number, void> {
+      return { done: true, value: undefined };
+    }
+
+    [Symbol.iterator](): Generator<number, any, unknown> {
+      return this;
+    }
+  }
+
+  class Range extends Iterator {
+    readonly #start: number;
+    readonly #end: number;
+    readonly #step: number;
+
+    constructor(start: number, end: number, step = 1) {
+      this.#start = start;
+      this.#end = end;
+      this.#step = step;
+    }
+
+    *[Symbol.iterator]() {
+      for (let value = this.#start; value <= this.#end; value += this.#step) {
+        yield value;
+      }
+    }
+  }
+  ```
+
+  Then you can use any of these as a retry strategy (note that these examples
+  assume you have access to [the ES2025 iterator helper methods][helpers]):
+
+  [helpers]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator#iterator_helper_methods
+
+  ```ts
+  import * as Task from 'true-myth/task';
+  import { someRetryableTask } from 'somewhere/in/your-app';
+
+  let usingRandomInRange = Task.withRetries(
+    someRetryableTask,
+    randomInRange(1, 100).take(10)
+  );
+
+  let usingRandomInteger = Task.withRetries(
+    someRetryableTask,
+    new RandomInteger().take(10)
+  );
+
+  let usingRangeIterator = Task.withRetries(
+    someRetryableTask,
+    new Range(1, 100, 5).take(10)
+  );
+  ```
+ */
+export interface Strategy extends Generator<number> {}
+
+/**
+  Generate an infinite iterable of integers beginning with `base` and increasing
+  exponentially until reaching `Number.MAX_SAFE_INTEGER`, after which the
+  generator will continue yielding `Number.MAX_SAFE_INTEGER` forever.
+
+  By default, this increases exponentially by a factor of 2; you may optionally
+  pass `{ factor: someOtherValue }` to change the exponentiation factor.
+
+  If you pass a non-integral value as `base`, it will be rounded to the nearest
+  integral value using `Math.round`.
+ */
+export function* exponential(options?: {
+  /** Initial delay duration in milliseconds. Default is `1`. */
+  from?: number;
+  /**
+    Exponentiation factor. Default is `2`.
+
+    > [!IMPORTANT]
+    > Setting this to a value less than `1` will cause the delay intervals to
+    > *decay* rather than *increase*. This is rarely what you want!
+   */
+  withFactor?: number;
+}): Strategy {
+  const factor = options?.withFactor ?? 2;
+  let curr = options?.from ? Math.round(options.from) : 1;
+  while (true) {
+    yield curr;
+    let next = curr * factor;
+    curr = Math.min(next, Number.MAX_SAFE_INTEGER);
+  }
+}
+
+/**
+  Generate an infinite iterable of integers beginning with `base` and
+  increasing as a Fibonacci sequence (1, 1, 2, 3, 5, 8, 13, ...) until reaching
+  `Number.MAX_SAFE_INTEGER`, after which the generator will continue yielding
+  `Number.MAX_SAFE_INTEGER` forever.
+
+  If you pass a non-integral value as the `from` property on the configuration
+  argument, it will be rounded to the nearest integral value using `Math.round`.
+ */
+export function* fibonacci(options?: {
+  /** Initial delay duration in milliseconds. Default is `1`. */
+  from: number;
+}): Strategy {
+  let integralBase = options?.from ? Math.round(options.from) : 1;
+  let curr = integralBase;
+  let next = integralBase;
+  while (true) {
+    yield curr;
+    let next_next = curr + next;
+    curr = next;
+    next = Math.min(next_next, Number.MAX_SAFE_INTEGER);
+  }
+}
+
+/**
+  Generate an infinite iterable of the same integer value in milliseconds.
+
+  If you pass a non-integral value, like `{ at: 2.5 }`, it will be rounded to
+  the nearest integral value using `Math.round`, i.e. `3` in that case.
+ */
+export function* fixed(options?: {
+  /** Delay duration in milliseconds. Default is `1` (immediate). */
+  at: number;
+}): Strategy {
+  let integralValue = options?.at ? Math.round(options.at) : 1;
+  while (true) {
+    yield integralValue;
+  }
+}
+
+/** Generate an infinite iterable of the value `0`. */
+export function* immediate() {
+  while (true) {
+    yield 0;
+  }
+}
+
+/**
+  Generate an infinite iterable of integers beginning with `base` and increasing
+  linearly (1, 2, 3, 4, 5, 5, 7, ...) until reaching `Number.MAX_SAFE_INTEGER`,
+  after which the generator will continue yielding `Number.MAX_SAFE_INTEGER`
+  forever.
+
+  By default, this increases by a step size of 1; you may optionally pass
+  `{ step: someOtherValue }` to change the step size.
+
+  If you pass a non-integral value as `base`, it will be rounded to the nearest
+  integral value using `Math.round`.
+ */
+export function* linear(options?: {
+  /** Initial delay duration in milliseconds. Default is `0`. */
+  from?: number;
+  /**
+      Step size by which to increase the value. Default is `1`.
+
+      > [!IMPORTANT]
+      > Setting this to a value less than `1` will cause the delay intervals to
+      > *decay* rather than *increase*. This is rarely what you want!
+     */
+  withStepSize?: number;
+}): Strategy {
+  const step = options?.withStepSize ?? 1;
+  let curr = options?.from ? Math.round(options.from) : 0;
+  while (true) {
+    yield curr;
+    curr += step;
+  }
+}
+
+/**
+  A “no-op” strategy, for if you need to call supply a {@linkcode Strategy} to
+  a function but do not actually want to retry at all.
+
+  You should never use this directly with `Task.withRetries`; in the case where
+  you would, invoke the `Task` that would be retried directly (i.e. without
+  using `withRetries` at all) instead.
+ */
+export function* none(): Strategy {
+  return;
+}
+
+/**
+  Apply fully random jitter proportional to the number passed in. The resulting
+  value will never be larger than 2×n, and never less than 0.
+
+  This is useful for making sure your retries generally follow a given
+  {@linkcode Strategy}, but if multiple tasks start at the same time, they do
+  not all retry at exactly the same time.
+
+  @param n The value to apply random jitter to.
+*/
+export function jitter(n: number): number {
+  let direction = Math.random() > 0.5 ? 1 : -1;
+  let amount = Math.ceil(Math.random() * n);
+  let total = n + direction * amount;
+  return Math.max(total, 0);
+}


Test patch:
diff --git a/test/task.test.ts b/test/task.test.ts
index 801d13f..5096d81 100644
--- a/test/task.test.ts
+++ b/test/task.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, expectTypeOf, test } from 'vitest';
+import { afterEach, assert, beforeEach, describe, expect, expectTypeOf, test } from 'vitest';
 
 import Task, {
   InvalidAccess,
@@ -36,7 +36,19 @@ import Task, {
   timeout,
   Timeout,
   toPromise,
+  withRetries,
+  stopRetrying,
+  isRetryFailed,
 } from 'true-myth/task';
+import {
+  exponential,
+  fibonacci,
+  fixed,
+  immediate,
+  jitter,
+  linear,
+  none,
+} from 'true-myth/task/delay';
 import Maybe from 'true-myth/maybe';
 import Result from 'true-myth/result';
 import Unit from 'true-myth/unit';
@@ -2768,6 +2780,217 @@ describe('module-scope functions', () => {
       expect(unwrap(theResult)).toEqual(theValue);
     });
   });
+
+  describe('withRetries', () => {
+    test('when the task initially rejects but later resolves', async () => {
+      let theTask = withRetries(({ count }) => {
+        return count === 0
+          ? Task.reject('not the first time')
+          : Task.resolve('but the second will do!');
+      });
+
+      let theResult = await theTask;
+      expect(unwrap(theResult)).toEqual('but the second will do!');
+    });
+
+    describe('when the task never resolves', () => {
+      test('not using the `status` parameter', async () => {
+        let theTask = withRetries(() => {
+          return Task.reject('this test *always* rejects until the count runs out');
+        });
+
+        let theError = unwrapErr(await theTask);
+        assert(theError instanceof Error);
+        expect(isRetryFailed(theError));
+        expect(printError(theError)).toMatch(
+          /TrueMyth.Task.RetryFailed: Stopped retrying after 3 tries \(\d+ms\)/
+        );
+      });
+
+      test('using the `status` parameter', async () => {
+        let theCount = 2;
+        let theMessage = `maximum count is ${theCount}`;
+        let theTask = withRetries(({ count }) => {
+          if (count >= theCount) {
+            return stopRetrying(theMessage);
+          }
+
+          return Task.reject('this test *always* rejects until the count runs out');
+        });
+
+        let theError = unwrapErr(await theTask);
+        assert(theError instanceof Error);
+        let errorDesc = printError(theError);
+        expect(errorDesc).toMatch(
+          /TrueMyth\.Task\.RetryFailed: Stopped retrying after 2 tries \(\d+ms\)/
+        );
+        expect(errorDesc).toMatch(`\tcaused by: TrueMyth.Task.StopRetrying: ${theMessage}`);
+      });
+
+      test('when it rejects with `stopRetrying`', async () => {
+        let theMessage = 'any reason at all will do';
+        let theTask = withRetries(() => {
+          return Task.reject(stopRetrying(theMessage));
+        });
+
+        let theError = unwrapErr(await theTask);
+        assert(theError instanceof Error);
+        let errorDesc = printError(theError);
+        expect(errorDesc).toMatch(
+          /TrueMyth\.Task\.RetryFailed: Stopped retrying after 0 tries \(\d+ms\)/
+        );
+        expect(errorDesc).toMatch(`\tcaused by: TrueMyth.Task.StopRetrying: ${theMessage}`);
+      });
+
+      test('when it rejects with a non-zero duration', async () => {
+        let theResult = await withRetries(() => Task.reject('never succeeds'), take(fixed(), 5));
+        let theError = unwrapErr(theResult);
+        assert(theError instanceof Error);
+        expect(printError(theError)).toMatch(
+          /TrueMyth\.Task\.RetryFailed: Stopped retrying after 5 tries \(\d+ms\)/
+        );
+      });
+    });
+  });
+
+  describe('delays', () => {
+    describe('exponential', () => {
+      test('with default factor (2)', () => {
+        let values = Array.from(take(exponential(), 5));
+        expect(values).toEqual([1, 2, 4, 8, 16]);
+      });
+
+      test('with custom factor', () => {
+        let values = Array.from(take(exponential({ withFactor: 4 }), 5));
+        expect(values).toEqual([1, 4, 16, 64, 256]);
+      });
+
+      describe('with non-integral base', () => {
+        test('that should round down', () => {
+          let values = Array.from(take(exponential({ from: 1.1 }), 5));
+          expect(values).toEqual([1, 2, 4, 8, 16]);
+        });
+
+        test('that should round up', () => {
+          let values = Array.from(take(exponential({ from: 0.9 }), 5));
+          expect(values).toEqual([1, 2, 4, 8, 16]);
+        });
+      });
+    });
+
+    describe('fibonacci', () => {
+      test('with default values', () => {
+        let values = Array.from(take(fibonacci(), 5));
+        expect(values).toEqual([1, 1, 2, 3, 5]);
+      });
+
+      test('with initial value `1`', () => {
+        let values = Array.from(take(fibonacci({ from: 1 }), 5));
+        expect(values).toEqual([1, 1, 2, 3, 5]);
+      });
+
+      test('with initial value `2`', () => {
+        let values = Array.from(take(fibonacci({ from: 2 }), 5));
+        expect(values).toEqual([2, 2, 4, 6, 10]);
+      });
+
+      describe('with non-integral initial value', () => {
+        test('that should be rounded down', () => {
+          let values = Array.from(take(fibonacci({ from: 1.1 }), 5));
+          expect(values).toEqual([1, 1, 2, 3, 5]);
+        });
+
+        test('that should be rounded up', () => {
+          let values = Array.from(take(fibonacci({ from: 0.9 }), 5));
+          expect(values).toEqual([1, 1, 2, 3, 5]);
+        });
+      });
+    });
+
+    describe('fixed', () => {
+      test('with default initial value', () => {
+        let values = Array.from(take(fixed(), 5));
+        expect(values).toEqual([1, 1, 1, 1, 1]);
+      });
+
+      test('with integral value', () => {
+        let values = Array.from(take(fixed({ at: 5 }), 5));
+        expect(values).toEqual([5, 5, 5, 5, 5]);
+      });
+
+      test('with non-integral value', () => {
+        let values = Array.from(take(fixed({ at: 1.2 }), 5));
+        expect(values).toEqual([1, 1, 1, 1, 1]);
+      });
+    });
+
+    test('immediate', () => {
+      let values = Array.from(take(immediate(), 5));
+      expect(values).toEqual([0, 0, 0, 0, 0]);
+    });
+
+    describe('linear', () => {
+      test('with default initial value and step size', () => {
+        let values = Array.from(take(linear(), 10));
+        expect(values).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
+      });
+
+      test('with custom initial value', () => {
+        let values = Array.from(take(linear({ from: 1 }), 5));
+        expect(values).toEqual([1, 2, 3, 4, 5]);
+      });
+
+      test('with custom step size', () => {
+        let values = Array.from(take(linear({ withStepSize: 2 }), 5));
+        expect(values).toEqual([0, 2, 4, 6, 8]);
+      });
+
+      test('with non-integral value', () => {
+        let values = Array.from(take(linear({ from: 1.1, withStepSize: 2 }), 5));
+        expect(values).toEqual([1, 3, 5, 7, 9]);
+      });
+    });
+
+    test('none', () => {
+      let values = Array.from(none());
+      expect(values.length).toBe(0);
+    });
+
+    describe('jitter', () => {
+      let originalMathRandom: typeof Math.random;
+      beforeEach(() => {
+        originalMathRandom = Math.random;
+      });
+
+      afterEach(() => {
+        Math.random = originalMathRandom;
+      });
+
+      test('with random value below 0.5', () => {
+        Math.random = () => 0.25;
+
+        let input = [1, 2, 3];
+        let output = input.map(jitter);
+
+        for (let index in input) {
+          expect(output[index]).toBeLessThanOrEqual(input[index]! * 2);
+          expect(output[index]).toBeGreaterThanOrEqual(0);
+        }
+      });
+
+      test('with random value above 0.5', () => {
+        Math.random = () => 0.75;
+
+        let input = [1, 2, 3];
+        let output = input.map(jitter);
+
+        for (let index in input) {
+          expect(output[index]).toBeLessThanOrEqual(input[index]! * 2);
+          expect(output[index]).toBeGreaterThanOrEqual(0);
+        }
+      });
+    });
+  });
 });
 
 describe('type utilities', () => {
@@ -2821,3 +3044,25 @@ function stringify(reason: unknown): string {
 }
 
 function noOp() {}
+
+function* take<T>(iterable: Iterable<T>, count: number): IterableIterator<T> {
+  let taken = 0;
+  for (let item of iterable) {
+    if (taken >= count) {
+      return;
+    }
+
+    taken += 1;
+    yield item;
+  }
+}
+
+function printError(e: Error): string {
+  // prettier-ignore
+  let maybeCause =
+    e.cause instanceof Error ? Maybe.just(printError(e.cause)) :
+    Maybe.of(e.cause?.toString());
+
+  let cause = maybeCause.mapOr('', (cause) => `\n\tcaused by: ${cause}`);
+  return `${e.name}: ${e.message}${cause}`;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 4419094..63edaf4 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,6 +7,10 @@ export default defineConfig({
       'true-myth': path.resolve(__dirname, './src'),
     },
     include: ['test/*.test.ts'],
+    typecheck: {
+      enabled: true,
+      include: ['test/*.test.ts'],
+    },
     coverage: {
       reporter: ['text'],
       include: ['src/**/*.ts'],