From ebff6aa34aa8ee36b99424847388ad3cdbf8423d Mon Sep 17 00:00:00 2001 From: timiturn3r Date: Mon, 20 Jul 2026 12:26:17 +0100 Subject: [PATCH] feat: add computeRetryDelay helper for backoff with jitter (#585) --- src/utils/__tests__/retry-delay.utils.test.ts | 65 +++++++++++++++++++ src/utils/retry-delay.utils.ts | 21 ++++++ 2 files changed, 86 insertions(+) create mode 100644 src/utils/__tests__/retry-delay.utils.test.ts create mode 100644 src/utils/retry-delay.utils.ts diff --git a/src/utils/__tests__/retry-delay.utils.test.ts b/src/utils/__tests__/retry-delay.utils.test.ts new file mode 100644 index 0000000..7822885 --- /dev/null +++ b/src/utils/__tests__/retry-delay.utils.test.ts @@ -0,0 +1,65 @@ +import { computeRetryDelay } from '../retry-delay.utils'; + +describe('computeRetryDelay', () => { + let mathRandomSpy: jest.SpyInstance; + + beforeEach(() => { + mathRandomSpy = jest.spyOn(Math, 'random'); + }); + + afterEach(() => { + mathRandomSpy.mockRestore(); + }); + + it('returns approximately baseMs on attempt 0', () => { + // Set random to 0 to test lower bound + mathRandomSpy.mockReturnValue(0); + expect(computeRetryDelay(0, 1000, 10000)).toBe(1000); + + // Set random to 0.99 to test upper bound of jitter + mathRandomSpy.mockReturnValue(0.9999); + // Computed delay is 1000. Jitter is 0.9999 * (0.2 * 1000) = ~200 + // Expect result to be around 1199 + expect(computeRetryDelay(0, 1000, 10000)).toBe(1199); + }); + + it('doubles the base delay on attempt 1 with jitter applied', () => { + mathRandomSpy.mockReturnValue(0.5); // 10% jitter + + // base = 1000. attempt = 1 -> exponentialDelay = 2000 + // jitter = 0.5 * (0.2 * 2000) = 0.5 * 400 = 200 + // final delay = 2000 + 200 = 2200 + expect(computeRetryDelay(1, 1000, 10000)).toBe(2200); + }); + + it('doubles the delay on attempt 2', () => { + mathRandomSpy.mockReturnValue(0); + + // base = 1000. attempt = 2 -> exponentialDelay = 4000 + // jitter = 0 + expect(computeRetryDelay(2, 1000, 10000)).toBe(4000); + }); + + it('caps the delay at maxMs even without jitter', () => { + mathRandomSpy.mockReturnValue(0); + + // attempt 4 -> 1000 * 2^4 = 16000. Should cap at 10000 + expect(computeRetryDelay(4, 1000, 10000)).toBe(10000); + }); + + it('never exceeds maxMs even when jitter is applied at the max boundary', () => { + mathRandomSpy.mockReturnValue(0.9999); + + // attempt 4 -> 16000. Caps at 10000. + // jitter is applied to 10000 -> up to 2000. + // Result would be 12000, but should be clamped to maxMs (10000). + expect(computeRetryDelay(4, 1000, 10000)).toBe(10000); + + // Let's test a case right below maxMs where jitter would push it over + // base = 1000, attempt = 3 -> 8000. + // Max is 9000. + // jitter on 8000 is up to 1600. + // 8000 + 1600 = 9600 -> should cap at 9000. + expect(computeRetryDelay(3, 1000, 9000)).toBe(9000); + }); +}); diff --git a/src/utils/retry-delay.utils.ts b/src/utils/retry-delay.utils.ts new file mode 100644 index 0000000..66f68e2 --- /dev/null +++ b/src/utils/retry-delay.utils.ts @@ -0,0 +1,21 @@ +/** + * Computes the next retry delay using exponential backoff with jitter. + * + * Formula: delay = min(baseMs * 2^attempt, maxMs) + random(0, 0.2 * computed_delay) + * The final delay is capped at maxMs. + * + * @param attempt - The current retry attempt (0-indexed). + * @param baseMs - The base delay in milliseconds. + * @param maxMs - The maximum allowed delay. + * @returns The calculated delay in milliseconds. + */ +export function computeRetryDelay(attempt: number, baseMs: number, maxMs: number): number { + const exponentialDelay = baseMs * Math.pow(2, attempt); + const computedDelay = Math.min(exponentialDelay, maxMs); + + // Add up to 20% jitter to the computed delay + const jitter = Math.random() * (0.2 * computedDelay); + + // Ensure the final delay with jitter never exceeds maxMs + return Math.floor(Math.min(computedDelay + jitter, maxMs)); +}