Skip to content

Commit

Permalink
feat(tryuntilasync, expbackoffasync, expbackoffcalculator): improveme…
Browse files Browse the repository at this point in the history
…nts to tryUntil

Added exponential backoff delay logic for `tryUntilAsync`. Upgraded package version to 6.1.0. This
involves updating delay function handling, adding new 'expBackoffAsync' function, and associated
unit testing. Also included an updated and more streamlined approach by adding new
'expBackoffCalculator'.
  • Loading branch information
Marviel committed Sep 28, 2023
1 parent cbb9814 commit 5750ea6
Show file tree
Hide file tree
Showing 8 changed files with 292 additions and 8 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lukebechtel/lab-ts-utils",
"version": "6.0.1",
"version": "6.1.0",
"description": "A library of small, self-contained utility functions for use in TypeScript projects.",
"main": "./lib/src/index.js",
"types": "./lib/src/index.d.ts",
Expand Down
65 changes: 65 additions & 0 deletions src/functions/expBackoffAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { SimpleLogger } from './simpleLogger';

interface ExpBackoffAsyncArgs {
/**
* Which iteration this was.
*
* Algorithm is:
* waitTimeMs = startMs * (multiplier ^ iteration)
*/
iteration: number;

/**
* Start time for the first iteration.
*
* Defaults to 1000.
*
*/
startMs?: number;

/**
* What multiplier is used in the exponential backout wait time.
*
* Defaults to 2.
*
* Algorithm is:
* waitTimeMs = startMs * (multiplier ^ iteration)
*/
multiplier?: number;

/**
* A logger to use for logging.
*/
logger?: SimpleLogger;
}

interface ExpBackoffAsyncReturn {
timeWaited: number;
}


export async function expBackoffAsync(
args: ExpBackoffAsyncArgs
): Promise<ExpBackoffAsyncReturn> {
const {
startMs = 1000,
multiplier = 2,
iteration,
logger
} = args;

logger?.debug('expBackoffAsync', { startMs, multiplier, iteration })

const waitTimeMs = startMs * (multiplier ** iteration);

logger?.debug('waiting', waitTimeMs)

return new Promise<ExpBackoffAsyncReturn>((resolve, reject) => {
setTimeout(() => {
logger?.debug('waited', waitTimeMs)
resolve({
timeWaited: waitTimeMs
});
}, waitTimeMs);
});
}
54 changes: 54 additions & 0 deletions src/functions/expBackoffCalculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { SimpleLogger } from './simpleLogger';

interface ExponentialBackoffCalculatorArgs {
/**
* Start time for the first iteration.
*
* Defaults to 1000.
*
*/
startMs?: number;

/**
* What multiplier is used in the exponential backout wait time.
*
* Defaults to 2.
*
* Algorithm is:
* waitTimeMs = startMs * (multiplier ^ iteration)
*/
multiplier?: number;

/**
* Which iteration this was.
*
* Algorithm is:
* waitTimeMs = startMs * (multiplier ^ iteration)
*/
iteration: number;

/**
* A logger to use for logging.
*/
logger?: SimpleLogger;
}

/**
* Calculates the wait time for exponential backoff.
*
* Returns: `startMs * (multiplier ^ iteration)`
*
* @param args - Arguments for the exponential backoff calculator.
* @returns The wait time in milliseconds.
*/
export async function createExponentialBackoffCalculator(
args: ExponentialBackoffCalculatorArgs
) {
const {
startMs = 1000,
multiplier = 2,
iteration
} = args;

return startMs * (multiplier ** iteration);
}
2 changes: 2 additions & 0 deletions src/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export * from './typedUuidV4';
export * from './types';
export * from './tryUntilAsync';
export * from './simpleLogger';
export * from './expBackoffAsync';
export * from './expBackoffCalculator';
66 changes: 62 additions & 4 deletions src/functions/tryUntilAsync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { RingBuffer } from 'ring-buffer-ts';

import { expBackoffAsync } from './expBackoffAsync';

export interface TryUntilTryLimitsOptions {
/**
* The maximum number of attempts to try,
Expand Down Expand Up @@ -111,8 +113,9 @@ export interface TryUntilOptions<TReturn> {
* Defaults to: `{ ms: 1000 }`
*/
delay?: {
type?: 'old';
/**
* The amount of time to wait between attempts.
* The amount of time to wait between attempts, in milliseconds.
*/
ms?: number;
/**
Expand All @@ -130,7 +133,50 @@ export interface TryUntilOptions<TReturn> {
err?: Error;
pastErrors: Error[];
}) => Promise<void>;
};
} | {
type: 'scalar';
/**
* The amount of time to wait between attempts, in milliseconds.
*/
ms: number;
} | {
type: 'expBackoff';
/**
* The starting time for the first iteration.
*
* Defaults to 1000.
*
* Algorithm is:
* waitTimeMs = startMs * (multiplier ^ iteration)
*/
startMs?: number;
/**
* The multiplier to use for exponential backoff.
*
* Defaults to 2.
*
* Algorithm is:
* waitTimeMs = startMs * (multiplier ^ iteration)
*/
multiplier?: number;
} | {
type: 'custom';
/**
* A function which can be used to delay the next attempt.
*
* The function should return a promise that resolves after a specified amount of time.
*
* If this is not provided, the function will use setTimeout.
*
* @returns A promise that resolves after the specified amount of time.
*/
delayFunction: (params: {
numFailedAttempts: number;
tryLimits: TryUntilTryLimitsOptions;
err?: Error;
pastErrors: Error[];
}) => Promise<void>;
}
}

/**
Expand Down Expand Up @@ -219,6 +265,7 @@ export function tryUntilAsync<TReturn>(
};

while (true) {
// console.log('Top of while')
try {
// Because setTimeout only accepts a 32-bit int, we need to limit the maxTimePerAttemptMS
// to the max value of a 32-bit int minus 2.
Expand Down Expand Up @@ -298,18 +345,29 @@ export function tryUntilAsync<TReturn>(
}

// Wait for delay
if (delay.ms) {
if (delay.type === 'expBackoff') {
// console.log('expBackoff start')
await expBackoffAsync({
iteration: attempts - 1,
startMs: delay.startMs,
multiplier: delay.multiplier,
})
// console.log('expBackoff done')
continue;
} else if (!delay.type && delay.ms) {
await new Promise(delayRes => {
delayTimeout = setTimeout(delayRes, delay.ms);
});
continue;
} else if (delay.delayFunction) {
} else if ((delay.type === undefined || delay.type === 'custom') && delay.delayFunction) {
// console.log('custom start')
await delay.delayFunction({
numFailedAttempts: attempts,
tryLimits,
err: pastErrors.getLast(),
pastErrors: pastErrors.toArray(),
});
// console.log('custom done')
continue;
} else {
continue;
Expand Down
42 changes: 42 additions & 0 deletions test/expBackoffAsync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expBackoffAsync } from '../src';

// Mock timers
jest.useFakeTimers();

describe('expBackoffAsync', () => {
// Spy on setTimeout
let setTimeoutSpy: any

beforeEach(() => {
setTimeoutSpy = jest.spyOn(global, 'setTimeout');
});

afterEach(() => {
setTimeoutSpy.mockRestore();
});

test('should resolve after correct wait time', async () => {
const iteration = 1;
const startMs = 1000;
const multiplier = 2;
const expectedWaitTimeMs = startMs * (multiplier ** iteration);

// Make sure our spy is set up correctly
expect(setTimeoutSpy).not.toBeCalled();

const mockResult = expBackoffAsync({
iteration,
startMs,
multiplier,
});

expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedWaitTimeMs);

jest.advanceTimersByTime(expectedWaitTimeMs);

await expect(mockResult).resolves.toEqual({
timeWaited: expectedWaitTimeMs
});
});
});
6 changes: 3 additions & 3 deletions test/tryUntil.advanced.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ jest.useFakeTimers();

describe('tryUntilAsync Advanced', () => {
let promiseResolver: (val?: any) => void;

beforeEach(() => {
promiseResolver = undefined!;
});

afterEach(() => {
jest.clearAllTimers();
});
Expand All @@ -31,7 +31,7 @@ describe('tryUntilAsync Advanced', () => {

// Simulate passing of 2 minute time
jest.advanceTimersByTime(twoMinutesInMs);

// Resolve the promise after 2 minutes
promiseResolver();

Expand Down
63 changes: 63 additions & 0 deletions test/tryUntil.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,67 @@ describe('tryUntilAsync', () => {
await expect(promise).rejects.toThrowError(errorMessage);
expect(func).toHaveBeenCalledTimes(1);
});

test('scalar delay', async () => {
let attempts = 0;
const res = await tryUntilAsync({
func: async ({ numPreviousTries }) => {
attempts += 1;
if (numPreviousTries < 3) {
throw new Error();
} else {
return true;
}
},
maxErrorHistory: 5,
delay: {
type: 'scalar',
ms: 1000,
},
});
expect(attempts).toBe(4);
});

test('expBackoff delay', async () => {
let attempts = 0;
const res = await tryUntilAsync({
func: async ({ numPreviousTries }) => {
attempts += 1;
if (numPreviousTries < 3) {
throw new Error();
} else {
return true;
}
},
maxErrorHistory: 5,
delay: {
type: 'expBackoff',
startMs: 100,
multiplier: 2,
},
});
expect(attempts).toBe(4);
});

test('custom delay', async () => {
let attempts = 0;
const res = await tryUntilAsync({
func: async ({ numPreviousTries }) => {
attempts += 1;
if (numPreviousTries < 3) {
throw new Error();
} else {
return true;
}
},
maxErrorHistory: 5,
delay: {
type: 'custom',
delayFunction: ({ numFailedAttempts }) => new Promise<void>((resolve) => {
setTimeout(resolve, numFailedAttempts * 100);
}),
},
});
expect(attempts).toBe(4);
});
});

0 comments on commit 5750ea6

Please sign in to comment.