diff options
| author | pack <pack@packgekko.xyz> | 2026-08-09 10:37:07 +0000 |
|---|---|---|
| committer | pack <pack@packgekko.xyz> | 2026-08-09 10:37:07 +0000 |
| commit | 55a4f1fc869e41aca748c63d3018f0448b1606e0 (patch) | |
| tree | d132d1d01772b6d53faee3e63c534dea5706b78a /node_modules/exponential-backoff/src | |
| download | crud-55a4f1fc869e41aca748c63d3018f0448b1606e0.tar.gz | |
first commit
Diffstat (limited to '')
14 files changed, 532 insertions, 0 deletions
diff --git a/node_modules/exponential-backoff/src/backoff.spec.ts b/node_modules/exponential-backoff/src/backoff.spec.ts new file mode 100644 index 0000000..24fd95b --- /dev/null +++ b/node_modules/exponential-backoff/src/backoff.spec.ts @@ -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); + }); + }); +}); diff --git a/node_modules/exponential-backoff/src/backoff.ts b/node_modules/exponential-backoff/src/backoff.ts new file mode 100644 index 0000000..549770b --- /dev/null +++ b/node_modules/exponential-backoff/src/backoff.ts @@ -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(); + } +} diff --git a/node_modules/exponential-backoff/src/delay/always/always.delay.spec.ts b/node_modules/exponential-backoff/src/delay/always/always.delay.spec.ts new file mode 100644 index 0000000..8a08cd8 --- /dev/null +++ b/node_modules/exponential-backoff/src/delay/always/always.delay.spec.ts @@ -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); + }); +}); diff --git a/node_modules/exponential-backoff/src/delay/always/always.delay.ts b/node_modules/exponential-backoff/src/delay/always/always.delay.ts new file mode 100644 index 0000000..ec86c62 --- /dev/null +++ b/node_modules/exponential-backoff/src/delay/always/always.delay.ts @@ -0,0 +1,3 @@ +import { Delay } from "../delay.base"; + +export class AlwaysDelay extends Delay {}
\ No newline at end of file diff --git a/node_modules/exponential-backoff/src/delay/delay.base.ts b/node_modules/exponential-backoff/src/delay/delay.base.ts new file mode 100644 index 0000000..bed90d6 --- /dev/null +++ b/node_modules/exponential-backoff/src/delay/delay.base.ts @@ -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; + } +} diff --git a/node_modules/exponential-backoff/src/delay/delay.factory.ts b/node_modules/exponential-backoff/src/delay/delay.factory.ts new file mode 100644 index 0000000..95e89fb --- /dev/null +++ b/node_modules/exponential-backoff/src/delay/delay.factory.ts @@ -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); +}
\ No newline at end of file diff --git a/node_modules/exponential-backoff/src/delay/delay.interface.ts b/node_modules/exponential-backoff/src/delay/delay.interface.ts new file mode 100644 index 0000000..6ecc8da --- /dev/null +++ b/node_modules/exponential-backoff/src/delay/delay.interface.ts @@ -0,0 +1,4 @@ +export interface IDelay { + apply: () => Promise<unknown>; + setAttemptNumber: (attempt: number) => void; +} diff --git a/node_modules/exponential-backoff/src/delay/skip-first/skip-first.delay.ts b/node_modules/exponential-backoff/src/delay/skip-first/skip-first.delay.ts new file mode 100644 index 0000000..9f2b7f5 --- /dev/null +++ b/node_modules/exponential-backoff/src/delay/skip-first/skip-first.delay.ts @@ -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; + } +}
\ No newline at end of file diff --git a/node_modules/exponential-backoff/src/jitter/full/full.jitter.spec.ts b/node_modules/exponential-backoff/src/jitter/full/full.jitter.spec.ts new file mode 100644 index 0000000..003d08d --- /dev/null +++ b/node_modules/exponential-backoff/src/jitter/full/full.jitter.spec.ts @@ -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)) + }) + }) +})
\ No newline at end of file diff --git a/node_modules/exponential-backoff/src/jitter/full/full.jitter.ts b/node_modules/exponential-backoff/src/jitter/full/full.jitter.ts new file mode 100644 index 0000000..b3c447b --- /dev/null +++ b/node_modules/exponential-backoff/src/jitter/full/full.jitter.ts @@ -0,0 +1,4 @@ +export function fullJitter(delay: number) { + const jitteredDelay = Math.random() * delay; + return Math.round(jitteredDelay); +}
\ No newline at end of file diff --git a/node_modules/exponential-backoff/src/jitter/jitter.factory.ts b/node_modules/exponential-backoff/src/jitter/jitter.factory.ts new file mode 100644 index 0000000..b549887 --- /dev/null +++ b/node_modules/exponential-backoff/src/jitter/jitter.factory.ts @@ -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; + } +} diff --git a/node_modules/exponential-backoff/src/jitter/no/no.jitter.spec.ts b/node_modules/exponential-backoff/src/jitter/no/no.jitter.spec.ts new file mode 100644 index 0000000..1d56524 --- /dev/null +++ b/node_modules/exponential-backoff/src/jitter/no/no.jitter.spec.ts @@ -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); + }) +})
\ No newline at end of file diff --git a/node_modules/exponential-backoff/src/jitter/no/no.jitter.ts b/node_modules/exponential-backoff/src/jitter/no/no.jitter.ts new file mode 100644 index 0000000..bc1b56f --- /dev/null +++ b/node_modules/exponential-backoff/src/jitter/no/no.jitter.ts @@ -0,0 +1,3 @@ +export function noJitter(delay: number) { + return delay; +}
\ No newline at end of file diff --git a/node_modules/exponential-backoff/src/options.ts b/node_modules/exponential-backoff/src/options.ts new file mode 100644 index 0000000..ccdee3a --- /dev/null +++ b/node_modules/exponential-backoff/src/options.ts @@ -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; +} |