Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/RETRY_BACKOFF_CONFIGURATION.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions listener/src/services/retry-backoff-config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
106 changes: 106 additions & 0 deletions listener/src/services/retry-backoff-config.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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<RetryBackoffOptions> = {},
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);
}