Task Retries and Delays
When working with a Task
, you often need to retry it in the case of failure. Maybe an API endpoint failed to respond in time, or perhaps it’s expected that a given operation may fail a few times before succeeding. True Myth’s task
module provides the withRetries
helper to make this easy both to do at all and to get things “right” in terms of your system’s performance.
Retrying a task
True Myth’s approach to retries is designed to let you compose all the pieces together yourself, and to opt into as much or as little complexity as you need.
The withRetries
function (API docs) accepts two arguments:
- A callback function that produces a
Task
. - Optionally, a retry strategy.
Although you can customize nearly every part of this, you do not have to customize any of it.
Here’s the simplest version of a function that retries a fetch
call:
import * as task from 'true-myth/task';
let fetcher = () => task.fromPromise(fetch('https://true-myth.js.org'));
let fetchWithRetries = task.withRetries(fetcher);
The fetchWithRetries
value has the type Task<Response, RetryFailed<unknown>>
. If the fetch
call succeeds, the Task
will resolve with the Response
from the fetch
call. If the fetch
call fails, withRetries
will retry it immediately, up to 3 times, before stopping. If it stops, the Task
will reject with a RetryFailed
type: a special error subclass that is only available within the true-myth/task
module. You can use its public APIs, as we will cover later, but you cannot construct one yourself: it only exists to allow True Myth to tell you that a Task
rejected because retries failed.
We also provide the following tools to customize the behavior of withRetries
:
- A retry status provided to the callback.
- The retry strategy argument.
- The
stopRetrying
helper.
In each of the following examples, we’ll use the following helper to give us slightly nicer types to work with in the case of rejections:
function intoError(cause: unknown): Error {
return new Error('unknown error', { cause });
}
We won’t repeat this, for the sake of simplicity.
The retry status
First, 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.
TIP
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.
Here’s one example of using the retry status in practice.
import Task, * as task from 'true-myth/task';
let fetcher = ({ count, elapsed }: task.RetryStatus): Task<Response, Error> => {
if (count > 100 || elapsed > 1_000) {
let message = `Overdid it! Count: ${count} | Time elapsed: ${elapsed}`;
return task.reject(new Error(message));
}
return task
.fromPromise(fetch('https://true-myth.js.org'))
.mapRejected(intoError);
};
let taskWithRetries = withRetries(fetcher);
In this example, we reject with an error if the task has already been retried more than 100 times or if it has taken more 1,000 milliseconds (one second). The resulting Task
has the type Task<Response, RetryFailed<Error>>
. The RetryFailed
type gathers up all the different kinds of errors emitted during retries so you can inspect them:
let described = taskWithRetries.orElse((retryFailed) => {
let tries = `Failed ${retryFailed.tries} times.`;
let time = `Took ${retryFailed.totalDuration}ms.`;
const LI = '\n- ';
let reasons =
retryFailed.rejections.length > 0
? LI +
retryFailed.rejections
.map((err) => {
let cause = err.cause ? `\nCaused by: ${err.cause}` : '';
let stack = err.stack ? `\n${err.stack}` : '';
return err.message + cause + stack;
})
.join(LI)
: '';
let description = `${tries}\n${time}\n${reasons}`;
return Task.reject(description);
});
A retry strategy
Second, you can supply a retry strategy, which tells task.withRetries
how often to retry, including how much to back off between retries. (We talk more about how strategies work in Retry strategies below.) 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.
For example, to use an exponential backoff strategy with the original fetcher
, starting at 10 milliseconds and increasing by a factor of 10 with each retry, you could write this:
import * as task from "true-myth/task";
import { exponential } from "true-myth/task/delay";
let fetcher = () =>
task.fromPromise(fetch("https://true-myth.js.org")).mapRejected(intoError);
let taskWithRetries = task.withRetries(
fetcher,
exponential({ from: 10, withFactor: 10 }),
);
The stopRetrying
helper
Third, you can stop retrying at any time, using the supplied stopRetrying()
function, which accepts a message describing why and an optional cause. Under the hood, this returns a custom Error
subclass that can only be constructed by calling that function, so that the withRetries
implementation can know that any instance of that error is a signal that it needs to stop all further retries immediately.
Combining these features
This next 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
):
import * as task from 'true-myth/task';
import { exponential, jitter } 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);
});
},
exponential({ from: 10, factor: 3 }).map(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()
. The goal here is “progressive disclosure of complexity”: out of the box, it does something reasonable, and you can opt into customizing it with quite a few options, and if you need totally custom behavior, you can supply your own generator or iterable iterator.
Retry strategies
In general, a retry strategy is a way of configuring a retry function for when it should retry: how long between the first retry and the second, the second and the third, and so on. In the wild, you will commonly see both linear and exponential backoff, for example.
True Myth’s task.withRetries
function doesn’t actually know anything about back-off strategies, though. Instead, task.withRetries
accepts any iterator that produces numbers. Each number produced by the iterator is used as the duration of time to wait before retrying again.
This way, we don’t have to bake in special handling for a handful of pre-configured backoff strategies. Instead, we can implement those common strategies and make them available to you, while also allowing you maximum flexibility to implement your own.
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, because an iterator—and in particular a generator function—is a very natural way to express a sequence of numbers produced on demand, potentially forever.
Credit where due!
True Myth borrowed this idea, and modeled this API, fairly directly on the the Rust retry
crate: it’s a brilliant insight, but the insight was not ours!
Built-in retry strategies
The strategies provided in this module represent the most common, but definitely not the only possible strategies you can use for retrying.
exponential
(API docs)fibonacci
(API docs)fixed
(API docs)immediate
(API docs)linear
(API docs)none
(API docs)
Additionally, the jitter
function provides a useful tool for generating random variations on a retry strategy, to help avoid “thundering herd” problems 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.
You should make sure you understand the tradeoffs of each of these backoff strategies before deploying them!
Custom retry strategies
There are three basic patterns for creating custom retry strategies:
- Using generator functions.
- Subclassing the
Iterator
class (requires ES2025 or a polyfill). - Implementing the
Iterator
interface.
For the following examples, we’ll use this function to get a Result
that usually rejects.
import Task from 'true-myth/task';
const unpredictable = () => Math.random() > 0.9
? Task.resolve("Success!")
: Task.reject(new Error("Nope, try again!"));
Using generator functions
Here’s an example of implementing a custom retry strategy using a generator function to produce up to 10 random backoffs of up to 10 seconds (10,000 milliseconds) each:
import * as task from 'true-myth/task';
function* random10Times(): task.delay.Strategy {
const MAX = 10_000;
let retries = 0;
while (retries < 10) {
yield Math.round(Math.random() * MAX);
retries += 1;
}
}
let taskWithRetries = task.withRetries(unpredictable, random10Times());
We use a generator function that yields a random integer somewhere between 0
and the max
passed in, up to 10 times. We do not have to explicitly name the Strategy
return type, but doing so makes sure that the generator function produces the expected type (number
), so it is a good idea. Then we can pass the result of calling the generator function directly as the strategy to Task.withRetries
, just like the built-in strategies.
Subclassing Iterator
class InRange extends Iterator<number> {
readonly #start: number;
readonly #end: number;
readonly #step: number;
#curr: number;
constructor(start: number, end: number, step = 1) {
super();
this.#start = start;
this.#end = end;
this.#step = step;
this.#curr = this.#start;
}
next() {
if (this.#curr < this.#end) {
let value = this.#curr;
this.#curr += this.#step;
return { value, done: false } as const;
} else {
return { value: undefined, done: true } as const;
}
}
}
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):
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,
Iterator.from(new RandomInteger()).take(10)
);
let usingRangeIterator = task.withRetries(
someRetryableTask,
new InRange(1, 100, 5).take(10)
);
Implementing the Iterator
interface
🚧 Under Construction 🚧
There will be more content here Soon™. We didn’t want to block getting the new docs site live on having finished updating all the existing content!
class RandomInteger implements Iterator<number> {
#nextValue: number;
constructor(initial: number) {
this.#nextValue = initial;
}
next(): IteratorResult<number, void> {
let value = this.#nextValue;
this.#nextValue = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
return { done: false, value };
}
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;
}
}
Limiting retries
All the helpers from true-myth/task/delay
are infinite except none
, so you almost certainly want to use another iterator helper to stop after a number of retries or to use the count
passed as an argument to do the same.
Recommended approach
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:
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 approach
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).
import * as task from 'true-myth/task';
import * as delay from 'true-myth/task/delay';
function* take<T>(iterator: Iterator<T>, count: number): Generator<T> {
let taken = 0;
let next = iterator.next();
while (!next.done) {
if (taken >= count) {
return;
}
taken += 1;
yield next.value;
next = iterator.next();
}
}
function* map<T, U>(iterator: Iterator<T>, fn: (t: T) => U): Generator<U> {
let next = iterator.next();
while (!next.done) {
yield fn(next.value);
next = iterator.next();
}
}
let theTask = task.withRetries(
() => task.fromPromise(fetch('https://example.com/')),
take(map(delay.exponential(), delay.jitter), 5),
);
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.
Design note: Strategy
You may notice that Strategy
is simply an alias for Iterator<number>
. This means that you can ignore it and use Iterator<number>
, Generator<number>
, or IterableIterator<number>
—really, anything that extends Iterator<number>
. Given that, you might wonder why we have Strategy
at all.
The answer is so that if we need to change the definition of a Strategy
in the future in some way, any place that uses