diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index 94ff838..32b4355 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -33,6 +33,8 @@ export class ExponentialBackoff { jitter: 0.1, ...options, }; + + ExponentialBackoff.validateOptions(this.#options); } /** @@ -95,6 +97,60 @@ export class ExponentialBackoff { return cappedDelay - jitter; } + /** + * Validate the options for the exponential backoff + * + * @param options - The options to validate + * + * @throws An error if the options are invalid + */ + public static validateOptions(options: ExponentialBackoffOptions): void { + // Validate the max delay is a finite number not less than 0 + if (!Number.isFinite(options.maxDelay)) { + throw new Error('maxDelay must be a finite number'); + } + + if (options.maxDelay < 0) { + throw new Error('maxDelay must be not less than 0'); + } + + // Validate the max attempts is a finite number not less than 0 + if (!Number.isFinite(options.maxAttempts)) { + throw new Error('maxAttempts must be a finite number'); + } + + if (options.maxAttempts < 0) { + throw new Error('maxAttempts must be not less than 0'); + } + + // Validate the base delay is a finite number not less than 0 + if (!Number.isFinite(options.baseDelay)) { + throw new Error('baseDelay must be a finite number'); + } + + if (options.baseDelay < 0) { + throw new Error('baseDelay must be not less than 0'); + } + + // Validate the growth rate is a finite number not less than 0 + if (!Number.isFinite(options.growthRate)) { + throw new Error('growthRate must be a finite number'); + } + + if (options.growthRate < 0) { + throw new Error('growthRate must be not less than 0'); + } + + // Validate the jitter is a finite number not less than 0 or greater than 1 + if (!Number.isFinite(options.jitter)) { + throw new Error('jitter must be a finite number'); + } + + if (options.jitter < 0 || options.jitter > 1) { + throw new Error('jitter must be not less than 0 or greater than 1'); + } + } + /** * Run the function with exponential backoff * diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts index 75e781c..4e073e8 100644 --- a/test/exponential-backoff.test.ts +++ b/test/exponential-backoff.test.ts @@ -2,6 +2,17 @@ import { expect, test, vi } from 'vitest'; import { ExponentialBackoff } from '../source/exponential-backoff.ts'; import { ExponentialBackoffMaxRetriesHitError } from '../source/errors.ts'; +/** + * A valid options object that satisfies {@link ExponentialBackoff.validateOptions}. + */ +const validExponentialBackoffOptions = { + maxDelay: 10_000, + maxAttempts: 10, + baseDelay: 1_000, + growthRate: 2, + jitter: 0.1, +}; + /** * Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance * with library defaults (including the default 1000ms base delay) when no options are passed. @@ -98,9 +109,11 @@ const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise => */ const testExponentialBackoffRetriesUntilSuccess = async (): Promise => { // Three invocations: two rejections then a success on the third call. - const tripleRejectFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) -.mockRejectedValueOnce(new Error('attempt 2')) -.mockResolvedValueOnce('success'); + const tripleRejectFn = vi + .fn() + .mockRejectedValueOnce(new Error('attempt 1')) + .mockRejectedValueOnce(new Error('attempt 2')) + .mockResolvedValueOnce('success'); // maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap. const result = await ExponentialBackoff.run(tripleRejectFn, () => {}, { @@ -351,6 +364,116 @@ const testExponentialBackoffAppliesJitter = async (): Promise => { } }; +/** + * Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1. + */ +const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => { + const validCases = [ + validExponentialBackoffOptions, + { + ...validExponentialBackoffOptions, + maxDelay: 0, + maxAttempts: 0, + baseDelay: 0, + growthRate: 0, + jitter: 0, + }, + { + ...validExponentialBackoffOptions, + jitter: 1, + }, + ] as const; + + for (const options of validCases) { + expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow(); + } +}; + +/** + * Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options. + */ +const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => { + // Define our test cases with each value being less than 0 + const negativeCases = [ + { field: 'maxDelay', value: -1 }, + { field: 'maxAttempts', value: -1 }, + { field: 'baseDelay', value: -1 }, + { field: 'growthRate', value: -1 }, + ] as const; + + // Iterate through the test cases and expect an error to be thrown + for (const { field, value } of negativeCases) { + expect(() => + ExponentialBackoff.validateOptions({ + ...validExponentialBackoffOptions, + [field]: value, + })).toThrow(`${field} must be not less than 0`); + } +}; + +/** + * Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1. + */ +const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => { + // Define our test cases with each value being less than 0 or greater than 1 + const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }]; + + // Iterate through the test cases and expect an error to be thrown + for (const { value } of invalidJitterCases) { + expect(() => + ExponentialBackoff.validateOptions({ + ...validExponentialBackoffOptions, + jitter: value, + })).toThrow('jitter must be not less than 0 or greater than 1'); + } +}; + +/** + * Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity. + */ +const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => { + // Define our test cases with each value being Infinity + const nonFiniteCases = [ + { field: 'maxDelay', value: Infinity }, + { field: 'maxAttempts', value: Infinity }, + { field: 'baseDelay', value: Infinity }, + { field: 'growthRate', value: Infinity }, + { field: 'jitter', value: Infinity }, + ] as const; + + // Iterate through the test cases and expect an error to be thrown + for (const { field, value } of nonFiniteCases) { + expect(() => + ExponentialBackoff.validateOptions({ + ...validExponentialBackoffOptions, + [field]: value, + })).toThrow(`${field} must be a finite number`); + } +}; + +/** + * Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite. + */ +const testExponentialBackoffValidateOptionsRejectsNaN = (): void => { + // Define our test cases with each value being NaN + const nanCases = [ + { field: 'maxDelay', value: Number.NaN }, + { field: 'maxAttempts', value: Number.NaN }, + { field: 'baseDelay', value: Number.NaN }, + { field: 'growthRate', value: Number.NaN }, + { field: 'jitter', value: Number.NaN }, + ] as const; + + // Iterate through the test cases and expect an error to be thrown + for (const { field, value } of nanCases) { + expect(() => + ExponentialBackoff.validateOptions({ + ...validExponentialBackoffOptions, + [field]: value, + })).toThrow(`${field} must be a finite number`); + } +}; + const runTests = async (): Promise => { test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions); test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions); @@ -368,6 +491,11 @@ const runTests = async (): Promise => { test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially); test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay); test('ExponentialBackoff: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter); + test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions); + test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues); + test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter); + test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues); + test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN); }; await runTests();