180 lines
4.7 KiB
JavaScript

/**
* Retry utilities with exponential backoff
*
* Features:
* - Simple retry with fixed delay
* - Exponential backoff with jitter
* - Callback hooks for logging/monitoring
*/
const sleep = (ms) => new Promise(res => setTimeout(res, ms));
/**
* Simple retry with fixed delay
*
* @param {Function} fn - Async function to retry
* @param {Object} options
* @param {number} options.retries - Number of retries (default: 3)
* @param {number} options.delayMs - Fixed delay between retries (default: 100)
* @param {Function} options.onRetry - Callback on retry attempt
* @returns {Promise} Result of fn
*/
export async function retry(fn, { retries = 3, delayMs = 100, onRetry } = {}) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (i === retries) {
break;
}
if (onRetry) {
onRetry({ attempt: i + 1, maxAttempts: retries + 1, err, delay: delayMs });
}
await sleep(delayMs);
}
}
throw lastErr;
}
/**
* Retry with exponential backoff and jitter
*
* @param {Function} fn - Async function to retry
* @param {Object} options
* @param {number} options.retries - Number of retries (default: 3)
* @param {number} options.baseDelayMs - Base delay for exponential backoff (default: 100)
* @param {number} options.maxDelayMs - Maximum delay cap (default: 2000)
* @param {boolean} options.jitter - Add random jitter (default: true)
* @param {Function} options.onRetry - Callback on retry attempt
* @returns {Promise} Result of fn
*/
export async function retryWithBackoff(fn, {
retries = 3,
baseDelayMs = 100,
maxDelayMs = 2000,
jitter = true,
onRetry
} = {}) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (i === retries) {
break;
}
// Exponential backoff: baseDelay * 2^attempt
const exponential = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, i));
// Add jitter: random value between 50% and 100% of exponential
const delay = jitter
? Math.floor(exponential * (0.5 + Math.random() * 0.5))
: exponential;
if (onRetry) {
onRetry({
attempt: i + 1,
maxAttempts: retries + 1,
delay,
err,
exponential
});
}
await sleep(delay);
}
}
throw lastErr;
}
/**
* Retry with circuit breaker pattern
* Fails fast after consecutive errors threshold
*
* @param {Function} fn - Async function to retry
* @param {Object} options
* @param {number} options.retries - Retries per attempt (default: 2)
* @param {number} options.failureThreshold - Failures before circuit opens (default: 5)
* @param {number} options.resetTimeoutMs - Time before circuit half-open (default: 30000)
* @param {Function} options.onRetry - Callback on retry
* @param {Function} options.onCircuitOpen - Callback when circuit opens
* @returns {Promise} Result of fn
*/
export class CircuitBreaker {
constructor({
retries = 2,
failureThreshold = 5,
resetTimeoutMs = 30_000
} = {}) {
this.retries = retries;
this.failureThreshold = failureThreshold;
this.resetTimeoutMs = resetTimeoutMs;
this.failureCount = 0;
this.state = 'closed'; // 'closed' | 'open' | 'half-open'
this.lastFailureTime = null;
}
async execute(fn, { onRetry, onCircuitOpen } = {}) {
// Check if circuit should reset
if (this.state === 'open') {
const timeSinceFailure = Date.now() - this.lastFailureTime;
if (timeSinceFailure >= this.resetTimeoutMs) {
this.state = 'half-open';
this.failureCount = 0;
} else {
throw new Error(`Circuit breaker is open (reset in ${this.resetTimeoutMs - timeSinceFailure}ms)`);
}
}
try {
const result = await retryWithBackoff(fn, { retries: this.retries, onRetry });
// Success: reset failure count
if (this.state === 'half-open') {
this.state = 'closed';
}
this.failureCount = 0;
return result;
} catch (err) {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'open';
if (onCircuitOpen) {
onCircuitOpen({ failureCount: this.failureCount });
}
}
throw err;
}
}
reset() {
this.state = 'closed';
this.failureCount = 0;
this.lastFailureTime = null;
}
getState() {
return {
state: this.state,
failureCount: this.failureCount,
failureThreshold: this.failureThreshold
};
}
}