true-myth
Version:
A library for safe functional programming in JavaScript, with first-class support for TypeScript
240 lines (201 loc) • 7.3 kB
text/typescript
/**
{@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 Iterable<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);
}