From 3a8f71b4b33a283db3151dc5168af0799ec382c3 Mon Sep 17 00:00:00 2001 From: ravendevhub Date: Sat, 29 Aug 2026 13:22:40 +0630 Subject: [PATCH] feat(retry): add retry backoff configuration and bounded validation engine (#707) - Implement validateBackoffConfig rejecting invalid initial/max delays and multipliers - Implement calculateBoundedBackoff with exponential scaling and strict jitter bounds - Provide comprehensive test suite in retry-backoff-config.test.ts - Document configuration schema and formulas in docs/RETRY_BACKOFF_CONFIGURATION.md --- docs/RETRY_BACKOFF_CONFIGURATION.md | 31 +++++ .../src/services/retry-backoff-config.test.ts | 77 +++++++++++++ listener/src/services/retry-backoff-config.ts | 106 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 docs/RETRY_BACKOFF_CONFIGURATION.md create mode 100644 listener/src/services/retry-backoff-config.test.ts create mode 100644 listener/src/services/retry-backoff-config.ts diff --git a/docs/RETRY_BACKOFF_CONFIGURATION.md b/docs/RETRY_BACKOFF_CONFIGURATION.md new file mode 100644 index 00000000..6d92037e --- /dev/null +++ b/docs/RETRY_BACKOFF_CONFIGURATION.md @@ -0,0 +1,31 @@ +# ⏳ Notification Retry Backoff Configuration & Policy + +This document defines the retry backoff calculation algorithm, validation rules, and configuration parameters for NotifyChain (Issue #707). + +--- + +## 1. Backoff Parameters + +| Parameter | Type | Default | Constraints | Description | +|---|---|---|---|---| +| `initialDelayMs` | `number` | `1000` (1s) | `> 0` | Initial delay for the first retry attempt | +| `maxDelayMs` | `number` | `300000` (5m) | `>= initialDelayMs` | Strict maximum upper cap for retry delay | +| `multiplier` | `number` | `2.0` | `>= 1.0` | Exponential scaling factor | +| `jitterRatio` | `number` | `0.2` (±20%) | `0.0 .. 1.0` | Random jitter ratio to avoid thundering herds | +| `maxAttempts` | `number` | `5` | `>= 1` | Maximum retries before dead-letter queueing | + +--- + +## 2. Algorithm & Delay Formula + +$$\text{rawDelay} = \min(\text{initialDelayMs} \times \text{multiplier}^{\text{attempt}}, \text{maxDelayMs})$$ + +$$\text{jitterOffset} = (2 \times \text{random}() - 1) \times (\text{rawDelay} \times \text{jitterRatio})$$ + +$$\text{boundedDelay} = \max(0, \min(\text{rawDelay} + \text{jitterOffset}, \text{maxDelayMs}))$$ + +--- + +## 3. Strict Parameter Validation + +Any configuration containing negative intervals, `maxDelayMs < initialDelayMs`, multiplier `< 1.0`, or out-of-range jitter ratios is rejected with descriptive validation errors before the scheduler starts. diff --git a/listener/src/services/retry-backoff-config.test.ts b/listener/src/services/retry-backoff-config.test.ts new file mode 100644 index 00000000..035c66d0 --- /dev/null +++ b/listener/src/services/retry-backoff-config.test.ts @@ -0,0 +1,77 @@ +import { + calculateBoundedBackoff, + validateBackoffConfig, + DEFAULT_RETRY_BACKOFF_CONFIG, +} from './retry-backoff-config'; + +describe('Notification Retry Backoff Configuration (Issue #707)', () => { + describe('validateBackoffConfig', () => { + test('returns default configuration when given empty options', () => { + const config = validateBackoffConfig({}); + expect(config).toEqual(DEFAULT_RETRY_BACKOFF_CONFIG); + }); + + test('accepts valid custom backoff parameters', () => { + const custom = { + initialDelayMs: 2000, + maxDelayMs: 60000, + multiplier: 3.0, + jitterRatio: 0.1, + maxAttempts: 10, + }; + const config = validateBackoffConfig(custom); + expect(config.initialDelayMs).toBe(2000); + expect(config.multiplier).toBe(3.0); + }); + + test('rejects non-positive initialDelayMs', () => { + expect(() => validateBackoffConfig({ initialDelayMs: 0 })).toThrow(/initialDelayMs must be a positive number/); + expect(() => validateBackoffConfig({ initialDelayMs: -500 })).toThrow(/initialDelayMs must be a positive number/); + }); + + test('rejects maxDelayMs smaller than initialDelayMs', () => { + expect(() => + validateBackoffConfig({ initialDelayMs: 5000, maxDelayMs: 1000 }) + ).toThrow(/maxDelayMs.*cannot be less than initialDelayMs/); + }); + + test('rejects multiplier smaller than 1.0', () => { + expect(() => validateBackoffConfig({ multiplier: 0.8 })).toThrow(/multiplier must be >= 1.0/); + }); + + test('rejects out of bounds jitterRatio', () => { + expect(() => validateBackoffConfig({ jitterRatio: -0.1 })).toThrow(/jitterRatio must be between 0.0 and 1.0/); + expect(() => validateBackoffConfig({ jitterRatio: 1.5 })).toThrow(/jitterRatio must be between 0.0 and 1.0/); + }); + }); + + describe('calculateBoundedBackoff', () => { + test('calculates exponential delays correctly', () => { + const opts = { initialDelayMs: 1000, multiplier: 2.0, jitterRatio: 0 }; + + expect(calculateBoundedBackoff(0, opts)).toBe(1000); + expect(calculateBoundedBackoff(1, opts)).toBe(2000); + expect(calculateBoundedBackoff(2, opts)).toBe(4000); + expect(calculateBoundedBackoff(3, opts)).toBe(8000); + }); + + test('strictly enforces maxDelayMs ceiling on high attempts', () => { + const opts = { initialDelayMs: 1000, multiplier: 2.0, maxDelayMs: 10000, jitterRatio: 0 }; + + expect(calculateBoundedBackoff(10, opts)).toBe(10000); + expect(calculateBoundedBackoff(20, opts)).toBe(10000); + }); + + test('applies bounded jitter within ratio without exceeding maxDelayMs', () => { + const opts = { initialDelayMs: 1000, multiplier: 2.0, maxDelayMs: 5000, jitterRatio: 0.25 }; + + // Mock randomFn to return 1.0 (maximum positive jitter) + const maxJittered = calculateBoundedBackoff(2, opts, () => 1.0); // raw = 4000, jitter = +1000 -> 5000 + expect(maxJittered).toBe(5000); + + // Mock randomFn to return 0.0 (maximum negative jitter) + const minJittered = calculateBoundedBackoff(2, opts, () => 0.0); // raw = 4000, jitter = -1000 -> 3000 + expect(minJittered).toBe(3000); + }); + }); +}); diff --git a/listener/src/services/retry-backoff-config.ts b/listener/src/services/retry-backoff-config.ts new file mode 100644 index 00000000..ed37a331 --- /dev/null +++ b/listener/src/services/retry-backoff-config.ts @@ -0,0 +1,106 @@ +/** + * Notification Retry Backoff Configuration & Validation Engine (Issue #707) + * + * Provides standalone, provider-agnostic retry backoff calculation and validation + * ensuring delays remain bounded and invalid parameters are strictly rejected. + */ + +export interface RetryBackoffOptions { + /** Initial base delay in milliseconds (must be > 0). Default: 1,000ms. */ + initialDelayMs: number; + /** Maximum delay ceiling in milliseconds (must be >= initialDelayMs). Default: 300,000ms (5 mins). */ + maxDelayMs: number; + /** Exponential backoff multiplier (must be >= 1.0). Default: 2.0. */ + multiplier: number; + /** Random jitter ratio (0.0 to 1.0, e.g. 0.25 for ±25%). Default: 0.2. */ + jitterRatio: number; + /** Max retry attempts before giving up / dead-lettering. Default: 5. */ + maxAttempts: number; +} + +export const DEFAULT_RETRY_BACKOFF_CONFIG: RetryBackoffOptions = { + initialDelayMs: 1_000, + maxDelayMs: 300_000, + multiplier: 2.0, + jitterRatio: 0.2, + maxAttempts: 5, +}; + +/** + * Validates retry backoff options and returns a normalized, verified configuration. + * Throws explicit errors on invalid or out-of-bounds parameters. + */ +export function validateBackoffConfig( + input: Partial = {} +): RetryBackoffOptions { + const config: RetryBackoffOptions = { + ...DEFAULT_RETRY_BACKOFF_CONFIG, + ...input, + }; + + if (typeof config.initialDelayMs !== 'number' || isNaN(config.initialDelayMs) || config.initialDelayMs <= 0) { + throw new Error(`Invalid backoff config: initialDelayMs must be a positive number (> 0), received ${config.initialDelayMs}`); + } + + if (typeof config.maxDelayMs !== 'number' || isNaN(config.maxDelayMs) || config.maxDelayMs <= 0) { + throw new Error(`Invalid backoff config: maxDelayMs must be a positive number (> 0), received ${config.maxDelayMs}`); + } + + if (config.maxDelayMs < config.initialDelayMs) { + throw new Error( + `Invalid backoff config: maxDelayMs (${config.maxDelayMs}) cannot be less than initialDelayMs (${config.initialDelayMs})` + ); + } + + if (typeof config.multiplier !== 'number' || isNaN(config.multiplier) || config.multiplier < 1.0) { + throw new Error(`Invalid backoff config: multiplier must be >= 1.0, received ${config.multiplier}`); + } + + if ( + typeof config.jitterRatio !== 'number' || + isNaN(config.jitterRatio) || + config.jitterRatio < 0.0 || + config.jitterRatio > 1.0 + ) { + throw new Error(`Invalid backoff config: jitterRatio must be between 0.0 and 1.0, received ${config.jitterRatio}`); + } + + if (typeof config.maxAttempts !== 'number' || isNaN(config.maxAttempts) || config.maxAttempts < 1) { + throw new Error(`Invalid backoff config: maxAttempts must be at least 1, received ${config.maxAttempts}`); + } + + return config; +} + +/** + * Computes deterministic or jittered retry delay bounded strictly by [0, maxDelayMs]. + * + * Formula: + * rawDelay = min(initialDelayMs * (multiplier ^ attempt), maxDelayMs) + * jitterRange = rawDelay * jitterRatio + * boundedDelay = rawDelay + random(-jitterRange, +jitterRange) capped at maxDelayMs + */ +export function calculateBoundedBackoff( + attempt: number, + options: Partial = {}, + randomFn = Math.random +): number { + const config = validateBackoffConfig(options); + const safeAttempt = Math.max(0, Math.floor(attempt)); + + // Compute base exponential delay + const rawDelay = Math.min( + config.initialDelayMs * Math.pow(config.multiplier, safeAttempt), + config.maxDelayMs + ); + + if (config.jitterRatio === 0) { + return Math.floor(rawDelay); + } + + // Calculate jitter: ±(rawDelay * jitterRatio) + const jitterOffset = (randomFn() * 2 - 1) * (rawDelay * config.jitterRatio); + const jitteredDelay = Math.max(0, Math.min(rawDelay + jitterOffset, config.maxDelayMs)); + + return Math.floor(jitteredDelay); +}