This commit is contained in:
CHEVALLIER Abel
2025-11-13 16:23:22 +01:00
parent de9c515a47
commit cb235644dc
34924 changed files with 3811102 additions and 0 deletions

194
node_modules/exponential-backoff/src/backoff.spec.ts generated vendored Normal file
View File

@@ -0,0 +1,194 @@
import { backOff } from "./backoff";
import { BackoffOptions } from "./options";
describe("BackOff", () => {
const mockSuccessResponse = { success: true };
const mockFailResponse = { success: false };
let backOffRequest: () => Promise<any>;
let backOffOptions: BackoffOptions;
function initBackOff() {
return backOff(backOffRequest, backOffOptions);
}
function promiseThatIsResolved() {
return () => Promise.resolve(mockSuccessResponse);
}
function promiseThatIsRejected() {
return () => Promise.reject(mockFailResponse);
}
function promiseThatFailsOnceThenSucceeds() {
return (() => {
let firstAttempt = true;
const request = () => {
if (firstAttempt) {
firstAttempt = false;
return Promise.reject(mockFailResponse);
}
return Promise.resolve(mockSuccessResponse);
};
return request;
})();
}
beforeEach(() => {
backOffOptions = { startingDelay: 0 };
backOffRequest = jest.fn(promiseThatIsResolved());
});
describe("when request function is a promise that resolves", () => {
it("returns the resolved value", () => {
const request = initBackOff();
return request.then(response =>
expect(response).toBe(mockSuccessResponse)
);
});
it("calls the request function only once", () => {
const request = initBackOff();
return request.then(() =>
expect(backOffRequest).toHaveBeenCalledTimes(1)
);
});
it(`when the #backOffOptions.numOfAttempts is 0,
it overrides the value and calls the method only once`, () => {
backOffOptions.numOfAttempts = 0;
const request = initBackOff();
return request.then(() =>
expect(backOffRequest).toHaveBeenCalledTimes(1)
);
});
});
describe(`when the #backOffOptions.startingDelay is 100ms`, () => {
const startingDelay = 100;
beforeEach(() => (backOffOptions.startingDelay = startingDelay));
it(`does not delay the first attempt`, () => {
const startTime = Date.now();
const request = initBackOff();
return request.then(() => {
const endTime = Date.now();
const duration = endTime - startTime;
const roundedDuration = Math.round(duration / 100) * 100;
expect(roundedDuration).toBe(0);
});
});
it(`when #backOffOptions.delayFirstAttempt is 'true',
it delays the first attempt`, () => {
backOffOptions.delayFirstAttempt = true;
const startTime = Date.now();
const request = initBackOff();
return request.then(() => {
const endTime = Date.now();
const duration = endTime - startTime;
const roundedDuration = Math.round(duration / 100) * 100;
expect(roundedDuration).toBe(startingDelay);
});
});
});
describe("when request function is a promise that is rejected", () => {
beforeEach(() => (backOffRequest = promiseThatIsRejected()));
it("returns the rejected value", () => {
const request = initBackOff();
return request.catch(err => expect(err).toBe(mockFailResponse));
});
it("retries the request as many times as specified in #BackOffOptions.numOfAttempts", async () => {
const numOfAttemps = 2;
backOffOptions.numOfAttempts = numOfAttemps;
backOffRequest = jest.fn(() => Promise.reject(mockFailResponse));
try {
await initBackOff();
} catch {
expect(backOffRequest).toHaveBeenCalledTimes(numOfAttemps);
}
});
it(`when the #BackOffOptions.retry function is set to always return false,
it only calls request function one time`, async () => {
backOffOptions.retry = () => false;
backOffOptions.numOfAttempts = 2;
backOffRequest = jest.fn(() => Promise.reject(mockFailResponse));
try {
await initBackOff();
} catch {
expect(backOffRequest).toHaveBeenCalledTimes(1);
}
});
});
it("when the #BackOffOptions.retry function returns a promise, it awaits it", async () => {
const retryDuration = 100;
backOffOptions.retry = () =>
new Promise(resolve => setTimeout(() => resolve(true), retryDuration));
backOffRequest = promiseThatFailsOnceThenSucceeds();
const start = Date.now();
await initBackOff();
const end = Date.now();
const duration = end - start;
const roundedDuration =
Math.round(duration / retryDuration) * retryDuration;
expect(roundedDuration).toBe(retryDuration);
});
describe(`when calling #backOff with a function that throws an error the first time, and succeeds the second time`, () => {
beforeEach(
() => (backOffRequest = jest.fn(promiseThatFailsOnceThenSucceeds()))
);
it(`returns a successful response`, () => {
const request = initBackOff();
return request.then(response =>
expect(response).toBe(mockSuccessResponse)
);
});
it("calls the request function two times", async () => {
await initBackOff();
expect(backOffRequest).toHaveBeenCalledTimes(2);
});
it(`when setting the #BackOffOption.timeMultiple to a value,
when setting the #BackOffOption.delayFirstAttempt to true,
it applies a delay between the first and the second call`, async () => {
const startingDelay = 100;
const timeMultiple = 3;
const totalExpectedDelay = startingDelay + timeMultiple * startingDelay;
backOffOptions.startingDelay = startingDelay;
backOffOptions.timeMultiple = timeMultiple;
backOffOptions.delayFirstAttempt = true;
const start = Date.now();
await initBackOff();
const end = Date.now();
const duration = end - start;
const roundedDuration =
Math.round(duration / startingDelay) * startingDelay;
expect(roundedDuration).toBe(totalExpectedDelay);
});
});
});

60
node_modules/exponential-backoff/src/backoff.ts generated vendored Normal file
View File

@@ -0,0 +1,60 @@
import {
IBackOffOptions,
getSanitizedOptions,
BackoffOptions
} from "./options";
import { DelayFactory } from "./delay/delay.factory";
export { BackoffOptions, IBackOffOptions };
/**
* Executes a function with exponential backoff.
* @param request the function to be executed
* @param options options to customize the backoff behavior
* @returns Promise that resolves to the result of the `request` function
*/
export async function backOff<T>(
request: () => Promise<T>,
options: BackoffOptions = {}
): Promise<T> {
const sanitizedOptions = getSanitizedOptions(options);
const backOff = new BackOff(request, sanitizedOptions);
return await backOff.execute();
}
class BackOff<T> {
private attemptNumber = 0;
constructor(
private request: () => Promise<T>,
private options: IBackOffOptions
) {}
public async execute(): Promise<T> {
while (!this.attemptLimitReached) {
try {
await this.applyDelay();
return await this.request();
} catch (e) {
this.attemptNumber++;
const shouldRetry = await this.options.retry(e, this.attemptNumber);
if (!shouldRetry || this.attemptLimitReached) {
throw e;
}
}
}
throw new Error("Something went wrong.");
}
private get attemptLimitReached() {
return this.attemptNumber >= this.options.numOfAttempts;
}
private async applyDelay() {
const delay = DelayFactory(this.options, this.attemptNumber);
await delay.apply();
}
}

View File

@@ -0,0 +1,65 @@
import { AlwaysDelay } from "./always.delay";
import { IBackOffOptions, getSanitizedOptions } from "../../options";
describe(AlwaysDelay.name, () => {
let options: IBackOffOptions;
let delay: AlwaysDelay;
function initClass() {
delay = new AlwaysDelay(options);
}
beforeEach(() => {
options = getSanitizedOptions({});
initClass();
jest.useFakeTimers();
});
it(`when calling #apply, the delay is equal to the starting delay`, async () => {
const spy = jest.fn();
delay.apply().then(spy);
jest.runTimersToTime(options.startingDelay);
await Promise.resolve();
expect(spy).toHaveBeenCalledTimes(1);
});
it(`when the attempt number is 1, when calling #apply,
the delay is equal to the starting delay multiplied by the time multiple`, async () => {
delay.setAttemptNumber(1);
const spy = jest.fn();
delay.apply().then(spy);
jest.runTimersToTime(options.startingDelay * options.timeMultiple);
await Promise.resolve();
expect(spy).toHaveBeenCalledTimes(1);
});
it(`when the attempt number is 2, when calling #apply,
the delay is equal to the starting delay multiplied by the time multiple raised by the attempt number`, async () => {
const attemptNumber = 2;
delay.setAttemptNumber(attemptNumber);
const spy = jest.fn();
delay.apply().then(spy);
jest.runTimersToTime(
options.startingDelay * Math.pow(options.timeMultiple, attemptNumber)
);
await Promise.resolve();
expect(spy).toHaveBeenCalledTimes(1);
});
it(`when the #maxDelay is less than #startingDelay, when calling #apply,
the delay is equal to the #maxDelay`, async () => {
options.maxDelay = options.startingDelay - 1;
const spy = jest.fn();
delay.apply().then(spy);
jest.runTimersToTime(options.maxDelay);
await Promise.resolve();
expect(spy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,3 @@
import { Delay } from "../delay.base";
export class AlwaysDelay extends Delay {}

View File

@@ -0,0 +1,34 @@
import { IDelay } from "./delay.interface";
import { IBackOffOptions } from "../options";
import { JitterFactory } from "../jitter/jitter.factory";
export abstract class Delay implements IDelay {
protected attempt = 0;
constructor(private options: IBackOffOptions) {}
public apply() {
return new Promise(resolve => setTimeout(resolve, this.jitteredDelay));
}
public setAttemptNumber(attempt: number) {
this.attempt = attempt;
}
private get jitteredDelay() {
const jitter = JitterFactory(this.options);
return jitter(this.delay);
}
private get delay() {
const constant = this.options.startingDelay;
const base = this.options.timeMultiple;
const power = this.numOfDelayedAttempts;
const delay = constant * Math.pow(base, power);
return Math.min(delay, this.options.maxDelay);
}
protected get numOfDelayedAttempts() {
return this.attempt;
}
}

View File

@@ -0,0 +1,18 @@
import { IBackOffOptions } from "../options";
import { SkipFirstDelay } from "./skip-first/skip-first.delay";
import { AlwaysDelay } from "./always/always.delay";
import { IDelay } from "./delay.interface";
export function DelayFactory(options: IBackOffOptions, attempt: number): IDelay {
const delay = initDelayClass(options);
delay.setAttemptNumber(attempt);
return delay;
}
function initDelayClass(options: IBackOffOptions) {
if (!options.delayFirstAttempt) {
return new SkipFirstDelay(options);
}
return new AlwaysDelay(options);
}

View File

@@ -0,0 +1,4 @@
export interface IDelay {
apply: () => Promise<unknown>;
setAttemptNumber: (attempt: number) => void;
}

View File

@@ -0,0 +1,15 @@
import { Delay } from "../delay.base";
export class SkipFirstDelay extends Delay {
public async apply() {
return this.isFirstAttempt ? true : super.apply();
}
private get isFirstAttempt() {
return this.attempt === 0;
}
protected get numOfDelayedAttempts() {
return this.attempt - 1;
}
}

View File

@@ -0,0 +1,31 @@
import { fullJitter } from './full.jitter';
describe(`Testing ${fullJitter.name}`, () => {
const delay = 100;
function arrayWith5FullJitterDelays() {
return Array(5).fill(delay).map(fullJitter)
}
describe(`when calling #fullJitter on the same delay multiple times`, () => {
it('all the delays are greater than or equal to 0', () => {
arrayWith5FullJitterDelays()
.forEach(value => expect(value).toBeGreaterThanOrEqual(0));
})
it('all the delays are less than or equal to the original delay', () => {
arrayWith5FullJitterDelays()
.forEach(value => expect(value).toBeLessThanOrEqual(delay));
})
it('the delays are not equal to one another', () => {
const delays = arrayWith5FullJitterDelays();
expect(new Set(delays).size).not.toBe(1);
})
it('the delays are integers', () => {
arrayWith5FullJitterDelays()
.forEach(value => expect(Number.isInteger(value)).toBe(true))
})
})
})

View File

@@ -0,0 +1,4 @@
export function fullJitter(delay: number) {
const jitteredDelay = Math.random() * delay;
return Math.round(jitteredDelay);
}

View File

@@ -0,0 +1,16 @@
import { IBackOffOptions } from "../options";
import { fullJitter } from "./full/full.jitter";
import { noJitter } from "./no/no.jitter";
export type Jitter = (delay: number) => number;
export function JitterFactory(options: IBackOffOptions): Jitter {
switch (options.jitter) {
case "full":
return fullJitter;
case "none":
default:
return noJitter;
}
}

View File

@@ -0,0 +1,9 @@
import { noJitter } from './no.jitter';
describe(`Testing ${noJitter.name}`, () => {
it(`when calling #noJitter with a delay,
it returns the same delay`, () => {
const delay = 100;
expect(noJitter(delay)).toBe(delay);
})
})

View File

@@ -0,0 +1,3 @@
export function noJitter(delay: number) {
return delay;
}

76
node_modules/exponential-backoff/src/options.ts generated vendored Normal file
View File

@@ -0,0 +1,76 @@
/**
* Type of jitter to apply to the delay.
* - `"none"`: no jitter is applied
* - `"full"`: full jitter is applied (random value between `0` and `delay`)
*/
export type JitterType = "none" | "full";
export type BackoffOptions = Partial<IBackOffOptions>;
export interface IBackOffOptions {
/**
* Decides whether the `startingDelay` should be applied before the first call.
* If `false`, the first call will occur without a delay.
* @defaultValue `false`
*/
delayFirstAttempt: boolean;
/**
* Decides whether a [jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
* should be applied to the delay. Possible values are `"full"` and `"none"`.
* @defaultValue `"none"`
*/
jitter: JitterType;
/**
* The maximum delay, in milliseconds, between two consecutive attempts.
* @defaultValue `Infinity`
*/
maxDelay: number;
/**
* The maximum number of times to attempt the function.
* Must be at least `1`.
* @defaultValue `10`
*/
numOfAttempts: number;
/**
* The `retry` function can be used to run logic after every failed attempt (e.g. logging a message,
* assessing the last error, etc.).
* It is called with the last error and the upcoming attempt number.
* Returning `true` will retry the function as long as the `numOfAttempts` has not been exceeded.
* Returning `false` will end the execution.
* @defaultValue a function that always returns `true`.
* @param e The last error thrown by the function.
* @param attemptNumber The upcoming attempt number.
* @returns `true` to retry the function, `false` to end the execution
*/
retry: (e: any, attemptNumber: number) => boolean | Promise<boolean>;
/**
* The delay, in milliseconds, before executing the function for the first time.
* @defaultValue `100`
*/
startingDelay: number;
/**
* The `startingDelay` is multiplied by the `timeMultiple` to increase the delay between reattempts.
* @defaultValue `2`
*/
timeMultiple: number;
}
const defaultOptions: IBackOffOptions = {
delayFirstAttempt: false,
jitter: "none",
maxDelay: Infinity,
numOfAttempts: 10,
retry: () => true,
startingDelay: 100,
timeMultiple: 2
};
export function getSanitizedOptions(options: BackoffOptions) {
const sanitized: IBackOffOptions = { ...defaultOptions, ...options };
if (sanitized.numOfAttempts < 1) {
sanitized.numOfAttempts = 1;
}
return sanitized;
}