/** * Circuit Breaker Pattern for CDC Connections * * @deprecated This module is deprecated and will be removed in a future version. * Please use the unified circuit breaker from @dotdo/postgres-shared instead: * * ```typescript * import { createUnifiedCircuitBreaker, CircuitOpenError } from '@dotdo/postgres-shared' * * const cb = createUnifiedCircuitBreaker({ * mode: 'single', * failureThreshold: 5, * resetTimeoutMs: 10000, * recoveryStrategy: 'active', // Uses timers like CDC implementation * }) * * // Execute with circuit breaker protection * const result = await cb.execute(async () => fetchData()) * * // Or with retry * const result = await cb.executeWithRetry(async () => fetchData(), { * maxRetries: 3, * delayMs: 1000, * }) * * // Cleanup when done * cb.destroy() * ``` * * Migration notes: * - CircuitState enum is replaced by string literal union: 'CLOSED' | 'OPEN' | 'HALF_OPEN' * - CDCCircuitOpenError is replaced by CircuitOpenError from @dotdo/postgres-shared * - successThreshold is now halfOpenSuccessThreshold * - resetTimeout is now resetTimeoutMs * * See packages/shared/src/circuit-breaker-unified.ts for the consolidated implementation. */ import { CDCCircuitOpenError, CDCErrorCode, CDCError } from './errors' import { CDCLogger, defaultLogger } from './logger' /** * Circuit breaker states * * @deprecated Use the string literal type from @dotdo/postgres-shared instead: * `type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'` */ export enum CircuitState { /** Normal operation - requests pass through */ CLOSED = 'CLOSED', /** Failing - requests are rejected immediately */ OPEN = 'OPEN', /** Testing recovery - limited requests allowed */ HALF_OPEN = 'HALF_OPEN', } /** * Circuit breaker configuration * * @deprecated Use UnifiedCircuitBreakerConfig from @dotdo/postgres-shared instead */ export interface CircuitBreakerConfig { /** Number of failures before opening the circuit (default: 5) */ failureThreshold?: number /** Number of successes in half-open state to close circuit (default: 2) */ successThreshold?: number /** Base time to wait before trying to recover (ms) (default: 10000) */ resetTimeout?: number /** Maximum reset timeout with exponential backoff (ms) (default: 60000) */ maxResetTimeout?: number /** Window size for counting failures (ms) (default: 60000) */ failureWindowMs?: number /** Callback when circuit state changes */ onStateChange?: (state: CircuitState, previousState: CircuitState) => void /** Logger instance */ logger?: CDCLogger } /** * Failure record for sliding window */ interface FailureRecord { timestamp: number error: Error } /** * Circuit breaker statistics * * @deprecated Use UnifiedCircuitBreakerStats from @dotdo/postgres-shared instead */ export interface CircuitBreakerStats { /** Current circuit state */ state: CircuitState /** Number of failures in current window */ failureCount: number /** Number of successes in half-open state */ halfOpenSuccesses: number /** Total failures since creation */ totalFailures: number /** Total successes since creation */ totalSuccesses: number /** Last failure timestamp */ lastFailure?: Date | undefined /** Last success timestamp */ lastSuccess?: Date | undefined /** Time until circuit resets (if open) */ resetInMs?: number | undefined /** Number of times circuit has opened */ openCount: number } /** * Circuit Breaker implementation for CDC connections * * @deprecated Use UnifiedCircuitBreaker from @dotdo/postgres-shared instead. * This class is maintained for backwards compatibility only. * * @example Migrating to the unified API: * ```typescript * // Before (deprecated): * import { CircuitBreaker, CircuitState } from './circuit-breaker' * const cb = new CircuitBreaker({ failureThreshold: 5, resetTimeout: 10000 }) * if (cb.getState() === CircuitState.CLOSED) { ... } * * // After (recommended): * import { createUnifiedCircuitBreaker } from '@dotdo/postgres-shared' * const cb = createUnifiedCircuitBreaker({ * mode: 'single', * failureThreshold: 5, * resetTimeoutMs: 10000, * recoveryStrategy: 'active', * }) * if (cb.getState() === 'CLOSED') { ... } * ``` */ export class CircuitBreaker { private state: CircuitState = CircuitState.CLOSED private failures: FailureRecord[] = [] private halfOpenSuccesses = 0 private resetTimer: ReturnType | null = null private currentResetTimeout: number private stats = { totalFailures: 0, totalSuccesses: 0, lastFailure: undefined as Date | undefined, lastSuccess: undefined as Date | undefined, openCount: 0, } private readonly config: Required< Omit > & { onStateChange?: CircuitBreakerConfig['onStateChange'] logger: CDCLogger } constructor(config: CircuitBreakerConfig = {}) { this.config = { failureThreshold: config.failureThreshold ?? 5, successThreshold: config.successThreshold ?? 2, resetTimeout: config.resetTimeout ?? 10000, maxResetTimeout: config.maxResetTimeout ?? 60000, failureWindowMs: config.failureWindowMs ?? 60000, onStateChange: config.onStateChange, logger: config.logger ?? defaultLogger, } this.currentResetTimeout = this.config.resetTimeout } /** * Get current circuit state */ getState(): CircuitState { return this.state } /** * Check if circuit allows requests */ isAllowed(): boolean { this.pruneOldFailures() switch (this.state) { case CircuitState.CLOSED: return true case CircuitState.OPEN: return false case CircuitState.HALF_OPEN: return true } } /** * Execute a function with circuit breaker protection * @throws CDCCircuitOpenError if circuit is open */ async execute(fn: () => Promise): Promise { // Check if request is allowed if (!this.isAllowed()) { const resetAt = new Date(Date.now() + (this.getResetTimeRemaining() ?? 0)) throw new CDCCircuitOpenError(resetAt, this.getFailureCount()) } try { const result = await fn() this.recordSuccess() return result } catch (error) { this.recordFailure(error instanceof Error ? error : new Error(String(error))) throw error } } /** * Execute with automatic retry when circuit is in HALF_OPEN */ async executeWithRetry( fn: () => Promise, maxRetries: number = 3, delayMs: number = 1000 ): Promise { let lastError: Error | undefined let attempt = 0 while (attempt < maxRetries) { try { return await this.execute(fn) } catch (error) { if (error instanceof CDCCircuitOpenError) { // Circuit is open - wait for reset const waitTime = error.resetAt.getTime() - Date.now() if (waitTime > 0 && attempt < maxRetries - 1) { await this.sleep(Math.min(waitTime, delayMs * Math.pow(2, attempt))) attempt++ continue } } lastError = error instanceof Error ? error : new Error(String(error)) attempt++ if (attempt < maxRetries) { await this.sleep(delayMs * Math.pow(2, attempt - 1)) } } } throw lastError ?? new CDCError('Max retries exceeded', { code: CDCErrorCode.MAX_RETRIES_EXCEEDED, retryable: false, context: { maxRetries, attempts: attempt }, }) } /** * Record a successful operation */ recordSuccess(): void { this.stats.totalSuccesses++ this.stats.lastSuccess = new Date() if (this.state === CircuitState.HALF_OPEN) { this.halfOpenSuccesses++ this.config.logger.debug('Circuit breaker: success in half-open state', { data: { successes: this.halfOpenSuccesses, threshold: this.config.successThreshold }, }) if (this.halfOpenSuccesses >= this.config.successThreshold) { this.transitionTo(CircuitState.CLOSED) // Reset backoff on successful recovery this.currentResetTimeout = this.config.resetTimeout } } } /** * Record a failed operation */ recordFailure(error: Error): void { this.stats.totalFailures++ this.stats.lastFailure = new Date() this.failures.push({ timestamp: Date.now(), error, }) this.config.logger.warn('Circuit breaker: failure recorded', { data: { errorMessage: error.message, failureCount: this.getFailureCount(), state: this.state, }, }) if (this.state === CircuitState.HALF_OPEN) { // Any failure in half-open state opens the circuit again this.transitionTo(CircuitState.OPEN) // Increase backoff for next recovery attempt this.currentResetTimeout = Math.min( this.currentResetTimeout * 2, this.config.maxResetTimeout ) } else if (this.state === CircuitState.CLOSED) { this.pruneOldFailures() if (this.failures.length >= this.config.failureThreshold) { this.transitionTo(CircuitState.OPEN) } } } /** * Force the circuit to a specific state (for testing/admin) */ forceState(state: CircuitState): void { this.transitionTo(state) } /** * Reset the circuit breaker to initial state */ reset(): void { if (this.resetTimer) { clearTimeout(this.resetTimer) this.resetTimer = null } this.failures = [] this.halfOpenSuccesses = 0 this.currentResetTimeout = this.config.resetTimeout this.transitionTo(CircuitState.CLOSED) } /** * Get circuit breaker statistics */ getStats(): CircuitBreakerStats { this.pruneOldFailures() return { state: this.state, failureCount: this.failures.length, halfOpenSuccesses: this.halfOpenSuccesses, totalFailures: this.stats.totalFailures, totalSuccesses: this.stats.totalSuccesses, lastFailure: this.stats.lastFailure, lastSuccess: this.stats.lastSuccess, resetInMs: this.getResetTimeRemaining(), openCount: this.stats.openCount, } } /** * Get failure count in current window */ getFailureCount(): number { this.pruneOldFailures() return this.failures.length } /** * Get time remaining until circuit attempts to reset (when open) */ getResetTimeRemaining(): number | undefined { if (this.state !== CircuitState.OPEN) { return undefined } // This is an approximation - we don't track exact timer start return this.currentResetTimeout } /** * Transition to a new state */ private transitionTo(newState: CircuitState): void { if (newState === this.state) { return } const previousState = this.state this.state = newState this.config.logger.info('Circuit breaker state changed', { data: { from: previousState, to: newState }, }) // Handle state entry switch (newState) { case CircuitState.OPEN: this.stats.openCount++ this.halfOpenSuccesses = 0 this.scheduleRecoveryAttempt() break case CircuitState.HALF_OPEN: this.halfOpenSuccesses = 0 break case CircuitState.CLOSED: this.failures = [] this.halfOpenSuccesses = 0 if (this.resetTimer) { clearTimeout(this.resetTimer) this.resetTimer = null } break } // Notify listener this.config.onStateChange?.(newState, previousState) } /** * Schedule transition to half-open for recovery attempt */ private scheduleRecoveryAttempt(): void { if (this.resetTimer) { clearTimeout(this.resetTimer) } this.config.logger.debug('Circuit breaker: scheduling recovery attempt', { data: { resetInMs: this.currentResetTimeout }, }) this.resetTimer = setTimeout(() => { if (this.state === CircuitState.OPEN) { this.transitionTo(CircuitState.HALF_OPEN) } }, this.currentResetTimeout) } /** * Remove failures outside the sliding window */ private pruneOldFailures(): void { const cutoff = Date.now() - this.config.failureWindowMs this.failures = this.failures.filter((f) => f.timestamp > cutoff) } /** * Sleep helper */ private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Cleanup timers. Call this when the circuit breaker is no longer needed. */ destroy(): void { if (this.resetTimer) { clearTimeout(this.resetTimer) this.resetTimer = null } } } /** * Create a circuit breaker instance * * @deprecated Use createUnifiedCircuitBreaker from @dotdo/postgres-shared instead */ export function createCircuitBreaker(config?: CircuitBreakerConfig): CircuitBreaker { return new CircuitBreaker(config) }