/** * @module @dotdo/postgres-shared/circuit-breaker * * Circuit Breaker Pattern for Cross-DO Communication Resilience * * This module implements the circuit breaker pattern to prevent cascading failures * when Durable Objects or external services become unavailable or unresponsive. * It provides a multi-instance tracking implementation that maintains separate * circuit state for each DO instance or service endpoint. * * ## Overview * * The circuit breaker pattern protects your application from repeated failures * by "tripping" after a threshold of consecutive failures, preventing further * requests until the service recovers. * * ## States * * - **CLOSED**: Normal operation, requests flow through to the protected service * - **OPEN**: Failures exceeded threshold, requests fail fast with CircuitOpenError * - **HALF_OPEN**: Testing recovery, limited requests allowed to probe service health * * ## Features * * - Multi-instance state management (track multiple DO instances independently) * - Configurable failure thresholds and time windows * - Exponential backoff for recovery attempts * - Event emission for monitoring and alerting * - Metrics collection integration * - DOCircuitBreakerWrapper for easy DO stub integration * - Passive recovery (state transitions on getState() check) * * ## Quick Start * * @example Basic usage with execute() * ```typescript * import { CircuitBreaker, CircuitOpenError } from '@dotdo/postgres-shared' * * const cb = new CircuitBreaker({ * failureThreshold: 5, * resetTimeoutMs: 30000, * }) * * try { * const result = await cb.execute('tenant-123', async () => { * return await doStub.fetch(request) * }) * } catch (error) { * if (error instanceof CircuitOpenError) { * // Circuit is open - respond with 503 * return new Response('Service temporarily unavailable', { * status: 503, * headers: { 'Retry-After': String(Math.ceil(error.retryAfterMs / 1000)) } * }) * } * throw error * } * ``` * * @example Using DOCircuitBreakerWrapper for DO stubs * ```typescript * import { DOCircuitBreakerWrapper } from '@dotdo/postgres-shared' * * const wrapper = new DOCircuitBreakerWrapper({ * failureThreshold: 3, * resetTimeoutMs: 10000, * fallbackResponse: () => new Response('Service unavailable', { status: 503 }), * }) * * // Wrap a DO stub * const protectedStub = wrapper.wrap(doStub, 'tenant-123') * * // Use the protected stub - circuit breaker handles failures automatically * const response = await protectedStub.fetch(request) * ``` * * @example With metrics collection * ```typescript * import { CircuitBreaker, DefaultMetricsCollector } from '@dotdo/postgres-shared' * * const collector = new DefaultMetricsCollector() * const cb = new CircuitBreaker({ * failureThreshold: 5, * metricsCollector: collector, * }) * * // Later, get metrics summary * const summary = collector.getSummary() * console.log('Open circuits:', summary.openCircuits) * console.log('Total failures:', summary.totalFailures) * ``` * * @see UnifiedCircuitBreaker for a flexible API supporting both single and multi-instance modes * @see packages/shared/src/circuit-breaker-unified.ts for the unified implementation */ // ============================================================================ // Types // ============================================================================ /** * Circuit breaker states */ export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN' /** * Circuit breaker event types for monitoring */ export type CircuitBreakerEvent = | { type: 'STATE_CHANGE'; from: CircuitState; to: CircuitState; instanceId: string; timestamp: number } | { type: 'FAILURE_RECORDED'; instanceId: string; failureCount: number; timestamp: number } | { type: 'SUCCESS_RECORDED'; instanceId: string; timestamp: number } | { type: 'REQUEST_REJECTED'; instanceId: string; state: CircuitState; timestamp: number } | { type: 'HALF_OPEN_TEST'; instanceId: string; success: boolean; timestamp: number } /** * Metrics collector interface for aggregating circuit breaker metrics */ export interface CircuitBreakerMetricsCollector { /** Record a state transition */ recordStateTransition(instanceId: string, from: CircuitState, to: CircuitState): void /** Record a failure */ recordFailure(instanceId: string, failureCount: number): void /** Record a success */ recordSuccess(instanceId: string): void /** Record a rejected request */ recordRejection(instanceId: string): void /** Record current backoff timeout for an instance */ recordBackoffTimeout(instanceId: string, timeoutMs: number): void /** Get aggregated metrics summary */ getSummary(): CircuitBreakerMetricsSummary } /** * Summary of aggregated circuit breaker metrics */ export interface CircuitBreakerMetricsSummary { totalInstances: number openCircuits: number halfOpenCircuits: number closedCircuits: number totalFailures: number totalSuccesses: number totalRejections: number averageBackoffMs: number } /** * Configuration options for the circuit breaker */ export interface CircuitBreakerConfig { /** Number of consecutive failures before opening the circuit (default: 5) */ failureThreshold?: number /** Base time in ms to wait before moving from OPEN to HALF_OPEN (default: 30000) */ resetTimeoutMs?: number /** Number of successful requests in HALF_OPEN to close circuit (default: 3) */ halfOpenSuccessThreshold?: number /** Time window in ms for counting failures (default: 60000) */ failureWindowMs?: number /** Optional event handler for circuit breaker events */ onEvent?: (event: CircuitBreakerEvent) => void /** Optional fallback function when circuit is open */ fallback?: () => Promise /** Enable exponential backoff for recovery timeouts (default: true) */ enableExponentialBackoff?: boolean /** Multiplier for exponential backoff (default: 2) */ backoffMultiplier?: number /** Maximum timeout in ms when using exponential backoff (default: 300000 = 5 minutes) */ maxResetTimeoutMs?: number /** Optional metrics collector for aggregating circuit breaker metrics */ metricsCollector?: CircuitBreakerMetricsCollector } /** * Statistics for a circuit breaker instance */ export interface CircuitBreakerStats { state: CircuitState failureCount: number successCount: number lastFailureTime: number | null lastSuccessTime: number | null lastStateChangeTime: number totalRequests: number totalFailures: number totalSuccesses: number totalRejected: number /** Number of consecutive circuit opens (for exponential backoff tracking) */ consecutiveOpens: number /** Current reset timeout in ms (may be increased due to exponential backoff) */ currentResetTimeoutMs: number } /** * Internal state tracking for a single DO instance */ interface InstanceState { state: CircuitState failureCount: number successCount: number lastFailureTime: number | null lastSuccessTime: number | null lastStateChangeTime: number openedAt: number | null failures: number[] // timestamps of recent failures totalRequests: number totalFailures: number totalSuccesses: number totalRejected: number /** Number of consecutive circuit opens (for exponential backoff) */ consecutiveOpens: number /** Current reset timeout being used (may be increased due to backoff) */ currentResetTimeoutMs: number } // ============================================================================ // Re-export CircuitOpenError from errors module // ============================================================================ // Re-export CircuitOpenError from errors.ts for convenience export { CircuitOpenError } from './errors.js' // Import for local use import { CircuitOpenError } from './errors.js' // ============================================================================ // Circuit Breaker Implementation // ============================================================================ /** * Circuit Breaker implementation * * Tracks failures per DO instance and manages state transitions. * * @example * ```typescript * const cb = new CircuitBreaker({ * failureThreshold: 5, * resetTimeoutMs: 30000, * onEvent: (event) => console.log('Circuit breaker event:', event), * }) * * try { * const result = await cb.execute('tenant-123', async () => { * return await doStub.fetch(request) * }) * } catch (error) { * if (error instanceof CircuitOpenError) { * // Circuit is open, fail fast * return new Response('Service temporarily unavailable', { status: 503 }) * } * throw error * } * ``` */ export class CircuitBreaker { private config: { failureThreshold: number resetTimeoutMs: number halfOpenSuccessThreshold: number failureWindowMs: number enableExponentialBackoff: boolean backoffMultiplier: number maxResetTimeoutMs: number onEvent?: (event: CircuitBreakerEvent) => void fallback?: () => Promise metricsCollector?: CircuitBreakerMetricsCollector } private instances: Map = new Map() constructor(config: CircuitBreakerConfig = {}) { this.config = { failureThreshold: config.failureThreshold ?? 5, resetTimeoutMs: config.resetTimeoutMs ?? 30000, halfOpenSuccessThreshold: config.halfOpenSuccessThreshold ?? 3, failureWindowMs: config.failureWindowMs ?? 60000, enableExponentialBackoff: config.enableExponentialBackoff ?? true, backoffMultiplier: config.backoffMultiplier ?? 2, maxResetTimeoutMs: config.maxResetTimeoutMs ?? 300000, // 5 minutes max } if (config.onEvent) this.config.onEvent = config.onEvent if (config.fallback) this.config.fallback = config.fallback if (config.metricsCollector) this.config.metricsCollector = config.metricsCollector } /** * Get or create state for a DO instance */ private getInstanceState(instanceId: string): InstanceState { let state = this.instances.get(instanceId) if (!state) { state = { state: 'CLOSED', failureCount: 0, successCount: 0, lastFailureTime: null, lastSuccessTime: null, lastStateChangeTime: Date.now(), openedAt: null, failures: [], totalRequests: 0, totalFailures: 0, totalSuccesses: 0, totalRejected: 0, consecutiveOpens: 0, currentResetTimeoutMs: this.config.resetTimeoutMs, } this.instances.set(instanceId, state) } return state } /** * Calculate the reset timeout with exponential backoff */ private calculateBackoffTimeout(consecutiveOpens: number): number { if (!this.config.enableExponentialBackoff || consecutiveOpens <= 1) { return this.config.resetTimeoutMs } // Exponential backoff: baseTimeout * multiplier^(consecutiveOpens - 1) const timeout = this.config.resetTimeoutMs * Math.pow(this.config.backoffMultiplier, consecutiveOpens - 1) return Math.min(timeout, this.config.maxResetTimeoutMs) } /** * Emit an event if handler is configured */ private emitEvent(event: CircuitBreakerEvent): void { if (this.config.onEvent) { this.config.onEvent(event) } } /** * Transition to a new state */ private transitionState(instanceId: string, instanceState: InstanceState, newState: CircuitState): void { const oldState = instanceState.state if (oldState === newState) return instanceState.state = newState instanceState.lastStateChangeTime = Date.now() if (newState === 'OPEN') { // Track consecutive opens for exponential backoff instanceState.consecutiveOpens++ instanceState.currentResetTimeoutMs = this.calculateBackoffTimeout(instanceState.consecutiveOpens) instanceState.openedAt = Date.now() instanceState.successCount = 0 // Report backoff timeout to metrics collector if (this.config.metricsCollector) { this.config.metricsCollector.recordBackoffTimeout(instanceId, instanceState.currentResetTimeoutMs) } } else if (newState === 'HALF_OPEN') { instanceState.successCount = 0 } else if (newState === 'CLOSED') { // Reset consecutive opens on successful recovery instanceState.consecutiveOpens = 0 instanceState.currentResetTimeoutMs = this.config.resetTimeoutMs instanceState.failureCount = 0 instanceState.failures = [] instanceState.openedAt = null } this.emitEvent({ type: 'STATE_CHANGE', from: oldState, to: newState, instanceId, timestamp: Date.now(), }) // Report to metrics collector if (this.config.metricsCollector) { this.config.metricsCollector.recordStateTransition(instanceId, oldState, newState) } } /** * Clean up old failures outside the failure window */ private cleanupOldFailures(instanceState: InstanceState): void { const cutoff = Date.now() - this.config.failureWindowMs instanceState.failures = instanceState.failures.filter(t => t > cutoff) } /** * Get the current state of the circuit for an instance */ getState(instanceId: string): CircuitState { const instanceState = this.getInstanceState(instanceId) // Check if we should transition from OPEN to HALF_OPEN if (instanceState.state === 'OPEN' && instanceState.openedAt) { const timeSinceOpen = Date.now() - instanceState.openedAt // Use the instance's current timeout (which may be increased due to backoff) if (timeSinceOpen >= instanceState.currentResetTimeoutMs) { this.transitionState(instanceId, instanceState, 'HALF_OPEN') } } return instanceState.state } /** * Check if a request is allowed to proceed */ canExecute(instanceId: string): boolean { const state = this.getState(instanceId) return state === 'CLOSED' || state === 'HALF_OPEN' } /** * Record a successful request */ recordSuccess(instanceId: string): void { const instanceState = this.getInstanceState(instanceId) const now = Date.now() instanceState.lastSuccessTime = now instanceState.totalRequests++ instanceState.totalSuccesses++ this.emitEvent({ type: 'SUCCESS_RECORDED', instanceId, timestamp: now, }) // Report to metrics collector if (this.config.metricsCollector) { this.config.metricsCollector.recordSuccess(instanceId) } if (instanceState.state === 'HALF_OPEN') { instanceState.successCount++ this.emitEvent({ type: 'HALF_OPEN_TEST', instanceId, success: true, timestamp: now, }) // Check if we have enough successes to close the circuit if (instanceState.successCount >= this.config.halfOpenSuccessThreshold) { this.transitionState(instanceId, instanceState, 'CLOSED') } } else if (instanceState.state === 'CLOSED') { // Reset failure count on success in CLOSED state instanceState.failureCount = 0 this.cleanupOldFailures(instanceState) } } /** * Record a failed request */ recordFailure(instanceId: string): void { const instanceState = this.getInstanceState(instanceId) const now = Date.now() instanceState.lastFailureTime = now instanceState.totalRequests++ instanceState.totalFailures++ if (instanceState.state === 'HALF_OPEN') { // Any failure in HALF_OPEN immediately reopens the circuit this.emitEvent({ type: 'HALF_OPEN_TEST', instanceId, success: false, timestamp: now, }) // Report to metrics collector if (this.config.metricsCollector) { this.config.metricsCollector.recordFailure(instanceId, instanceState.failureCount) } this.transitionState(instanceId, instanceState, 'OPEN') return } if (instanceState.state === 'CLOSED') { // Add failure timestamp and clean up old ones instanceState.failures.push(now) this.cleanupOldFailures(instanceState) instanceState.failureCount = instanceState.failures.length this.emitEvent({ type: 'FAILURE_RECORDED', instanceId, failureCount: instanceState.failureCount, timestamp: now, }) // Report to metrics collector if (this.config.metricsCollector) { this.config.metricsCollector.recordFailure(instanceId, instanceState.failureCount) } // Check if we should open the circuit if (instanceState.failureCount >= this.config.failureThreshold) { this.transitionState(instanceId, instanceState, 'OPEN') } } } /** * Execute a function with circuit breaker protection */ async execute(instanceId: string, fn: () => Promise): Promise { const instanceState = this.getInstanceState(instanceId) // Check current state (this also handles OPEN -> HALF_OPEN transition) const state = this.getState(instanceId) if (state === 'OPEN') { instanceState.totalRejected++ instanceState.totalRequests++ this.emitEvent({ type: 'REQUEST_REJECTED', instanceId, state, timestamp: Date.now(), }) // Report rejection to metrics collector if (this.config.metricsCollector) { this.config.metricsCollector.recordRejection(instanceId) } // Try fallback if available if (this.config.fallback) { return this.config.fallback() } // Use the instance's current timeout (which may be increased due to backoff) const retryAfterMs = instanceState.currentResetTimeoutMs - (Date.now() - (instanceState.openedAt ?? 0)) throw new CircuitOpenError(instanceId, state, Math.max(0, retryAfterMs)) } try { const result = await fn() this.recordSuccess(instanceId) return result } catch (error) { this.recordFailure(instanceId) throw error } } /** * Get statistics for an instance */ getStats(instanceId: string): CircuitBreakerStats { const instanceState = this.getInstanceState(instanceId) return { state: this.getState(instanceId), failureCount: instanceState.failureCount, successCount: instanceState.successCount, lastFailureTime: instanceState.lastFailureTime, lastSuccessTime: instanceState.lastSuccessTime, lastStateChangeTime: instanceState.lastStateChangeTime, totalRequests: instanceState.totalRequests, totalFailures: instanceState.totalFailures, totalSuccesses: instanceState.totalSuccesses, totalRejected: instanceState.totalRejected, consecutiveOpens: instanceState.consecutiveOpens, currentResetTimeoutMs: instanceState.currentResetTimeoutMs, } } /** * Get statistics for all tracked instances */ getAllStats(): Map { const stats = new Map() for (const instanceId of this.instances.keys()) { stats.set(instanceId, this.getStats(instanceId)) } return stats } /** * Manually reset the circuit for an instance */ reset(instanceId: string): void { const instanceState = this.getInstanceState(instanceId) const previousState = instanceState.state instanceState.state = 'CLOSED' instanceState.failureCount = 0 instanceState.successCount = 0 instanceState.failures = [] instanceState.openedAt = null instanceState.lastStateChangeTime = Date.now() instanceState.consecutiveOpens = 0 instanceState.currentResetTimeoutMs = this.config.resetTimeoutMs if (previousState !== 'CLOSED') { this.emitEvent({ type: 'STATE_CHANGE', from: previousState, to: 'CLOSED', instanceId, timestamp: Date.now(), }) } } /** * Manually force a circuit to open (useful for maintenance) */ forceOpen(instanceId: string): void { const instanceState = this.getInstanceState(instanceId) if (instanceState.state !== 'OPEN') { this.transitionState(instanceId, instanceState, 'OPEN') } } /** * Remove tracking for an instance */ remove(instanceId: string): boolean { return this.instances.delete(instanceId) } /** * Clear all tracked instances */ clear(): void { this.instances.clear() } /** * Get the current configuration */ getConfig(): Required> { return { failureThreshold: this.config.failureThreshold, resetTimeoutMs: this.config.resetTimeoutMs, halfOpenSuccessThreshold: this.config.halfOpenSuccessThreshold, failureWindowMs: this.config.failureWindowMs, enableExponentialBackoff: this.config.enableExponentialBackoff, backoffMultiplier: this.config.backoffMultiplier, maxResetTimeoutMs: this.config.maxResetTimeoutMs, } } /** * Get the current backoff timeout for an instance */ getBackoffTimeout(instanceId: string): number { const instanceState = this.getInstanceState(instanceId) return instanceState.currentResetTimeoutMs } /** * Check if a request is allowed to proceed (alias for canExecute) * @param instanceId - The instance ID to check (default: 'default') */ isAllowed(instanceId: string = 'default'): boolean { return this.canExecute(instanceId) } /** * Force the circuit to a specific state (for testing/admin) * @param instanceId - The instance ID * @param state - The state to force */ forceState(instanceId: string, state: CircuitState): void { const instanceState = this.getInstanceState(instanceId) if (state === 'CLOSED') { this.reset(instanceId) } else if (state === 'OPEN') { this.forceOpen(instanceId) } else if (state === 'HALF_OPEN') { // Force to half-open state const oldState = instanceState.state if (oldState !== 'HALF_OPEN') { instanceState.state = 'HALF_OPEN' instanceState.successCount = 0 instanceState.lastStateChangeTime = Date.now() this.emitEvent({ type: 'STATE_CHANGE', from: oldState, to: 'HALF_OPEN', instanceId, timestamp: Date.now(), }) } } } /** * Execute a function with circuit breaker protection and automatic retry * @param instanceId - The instance ID * @param fn - The function to execute * @param options - Retry options */ async executeWithRetry( instanceId: string, fn: () => Promise, options: { maxRetries?: number delayMs?: number } = {} ): Promise { const { maxRetries = 3, delayMs = 1000 } = options let lastError: Error | undefined let attempt = 0 while (attempt < maxRetries) { try { return await this.execute(instanceId, fn) } catch (error) { if (error instanceof CircuitOpenError) { // Circuit is open - wait for reset const waitTime = error.retryAfterMs 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 Error('Max retries exceeded') } /** * Sleep helper for retry logic */ private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Cleanup any resources (timers, etc.) * Call this when the circuit breaker is no longer needed. * Note: This only clears timers (if any), not the circuit state. */ destroy(): void { // Currently the circuit breaker uses passive recovery (checking state on getState), // so there are no timers to clean up. This method is provided for API compatibility // and future-proofing if active recovery is added. // We don't call clear() here to preserve the circuit state. } } // ============================================================================ // Default Metrics Collector // ============================================================================ /** * Default in-memory implementation of CircuitBreakerMetricsCollector * * Provides basic metrics aggregation for monitoring circuit breaker behavior. * * @example * ```typescript * const collector = new DefaultMetricsCollector() * const cb = new CircuitBreaker({ * metricsCollector: collector, * }) * * // Later, get metrics summary * const summary = collector.getSummary() * console.log('Open circuits:', summary.openCircuits) * ``` */ export class DefaultMetricsCollector implements CircuitBreakerMetricsCollector { private stateTransitions: Array<{ instanceId: string from: CircuitState to: CircuitState timestamp: number }> = [] private failures: Map = new Map() private successes: Map = new Map() private rejections: Map = new Map() private backoffTimeouts: Map = new Map() private currentStates: Map = new Map() recordStateTransition(instanceId: string, from: CircuitState, to: CircuitState): void { this.stateTransitions.push({ instanceId, from, to, timestamp: Date.now() }) this.currentStates.set(instanceId, to) } recordFailure(instanceId: string, _failureCount: number): void { this.failures.set(instanceId, (this.failures.get(instanceId) ?? 0) + 1) } recordSuccess(instanceId: string): void { this.successes.set(instanceId, (this.successes.get(instanceId) ?? 0) + 1) } recordRejection(instanceId: string): void { this.rejections.set(instanceId, (this.rejections.get(instanceId) ?? 0) + 1) } recordBackoffTimeout(instanceId: string, timeoutMs: number): void { this.backoffTimeouts.set(instanceId, timeoutMs) } getSummary(): CircuitBreakerMetricsSummary { let openCircuits = 0 let halfOpenCircuits = 0 let closedCircuits = 0 for (const state of this.currentStates.values()) { if (state === 'OPEN') openCircuits++ else if (state === 'HALF_OPEN') halfOpenCircuits++ else closedCircuits++ } let totalFailures = 0 for (const count of this.failures.values()) { totalFailures += count } let totalSuccesses = 0 for (const count of this.successes.values()) { totalSuccesses += count } let totalRejections = 0 for (const count of this.rejections.values()) { totalRejections += count } let totalBackoffMs = 0 let backoffCount = 0 for (const timeout of this.backoffTimeouts.values()) { totalBackoffMs += timeout backoffCount++ } return { totalInstances: this.currentStates.size, openCircuits, halfOpenCircuits, closedCircuits, totalFailures, totalSuccesses, totalRejections, averageBackoffMs: backoffCount > 0 ? totalBackoffMs / backoffCount : 0, } } /** * Get recent state transitions (useful for debugging) */ getRecentTransitions( limit: number = 100 ): Array<{ instanceId: string; from: CircuitState; to: CircuitState; timestamp: number }> { return this.stateTransitions.slice(-limit) } /** * Reset all collected metrics */ reset(): void { this.stateTransitions = [] this.failures.clear() this.successes.clear() this.rejections.clear() this.backoffTimeouts.clear() this.currentStates.clear() } } // ============================================================================ // DO Stub Interface and Wrapper // ============================================================================ /** * Durable Object stub interface (minimal for wrapping) */ export interface DOStub { fetch(request: Request): Promise } /** * Configuration for the DO Circuit Breaker Wrapper */ export interface DOCircuitBreakerConfig extends CircuitBreakerConfig { /** Function to get instance ID from a request */ getInstanceId?: (request: Request) => string /** Function to determine if a response should be treated as a failure */ isFailure?: (response: Response) => boolean /** Fallback response when circuit is open */ fallbackResponse?: () => Response } /** * Wrapper for Durable Object stubs with circuit breaker protection * * Wraps DO stub fetch calls with circuit breaker logic. * * @example * ```typescript * const wrapper = new DOCircuitBreakerWrapper({ * failureThreshold: 3, * resetTimeoutMs: 10000, * fallbackResponse: () => new Response('Service unavailable', { status: 503 }), * }) * * // Wrap a DO stub * const protectedStub = wrapper.wrap(doStub, 'tenant-123') * * // Use the protected stub * try { * const response = await protectedStub.fetch(request) * } catch (error) { * if (error instanceof CircuitOpenError) { * // Handle circuit open * } * } * ``` */ export class DOCircuitBreakerWrapper { private circuitBreaker: CircuitBreaker private config: DOCircuitBreakerConfig constructor(config: DOCircuitBreakerConfig = {}) { this.config = config const cbConfig: CircuitBreakerConfig = {} if (config.onEvent) cbConfig.onEvent = config.onEvent if (config.failureThreshold !== undefined) cbConfig.failureThreshold = config.failureThreshold if (config.resetTimeoutMs !== undefined) cbConfig.resetTimeoutMs = config.resetTimeoutMs if (config.halfOpenSuccessThreshold !== undefined) cbConfig.halfOpenSuccessThreshold = config.halfOpenSuccessThreshold if (config.failureWindowMs !== undefined) cbConfig.failureWindowMs = config.failureWindowMs if (config.enableExponentialBackoff !== undefined) cbConfig.enableExponentialBackoff = config.enableExponentialBackoff if (config.backoffMultiplier !== undefined) cbConfig.backoffMultiplier = config.backoffMultiplier if (config.maxResetTimeoutMs !== undefined) cbConfig.maxResetTimeoutMs = config.maxResetTimeoutMs if (config.metricsCollector) cbConfig.metricsCollector = config.metricsCollector this.circuitBreaker = new CircuitBreaker(cbConfig) } /** * Get instance ID from a request */ private getInstanceId(request: Request, stubId?: string): string { if (stubId) return stubId if (this.config.getInstanceId) { return this.config.getInstanceId(request) } // Default: use URL path as instance identifier const url = new URL(request.url) return url.pathname } /** * Check if a response indicates a failure */ private isFailure(response: Response): boolean { if (this.config.isFailure) { return this.config.isFailure(response) } // Default: 5xx errors are failures, 4xx are not (client errors) return response.status >= 500 } /** * Wrap a DO stub with circuit breaker protection */ wrap(stub: DOStub, stubId?: string): DOStub { return { fetch: async (request: Request): Promise => { const instanceId = this.getInstanceId(request, stubId) return this.circuitBreaker.execute(instanceId, async () => { const response = await stub.fetch(request) // Check if response is a failure if (this.isFailure(response)) { // Clone the response before throwing so original can still be used const clonedResponse = response.clone() const errorText = await clonedResponse.text().catch(() => 'Unknown error') throw new Error(`DO request failed with status ${response.status}: ${errorText}`) } return response }).catch((error) => { // If it's a circuit open error and we have a fallback, use it if (error instanceof CircuitOpenError && this.config.fallbackResponse) { return this.config.fallbackResponse() } throw error }) }, } } /** * Execute a fetch with circuit breaker protection (without wrapping) */ async fetch(stub: DOStub, request: Request, stubId?: string): Promise { const wrappedStub = this.wrap(stub, stubId) return wrappedStub.fetch(request) } /** * Get the underlying circuit breaker */ getCircuitBreaker(): CircuitBreaker { return this.circuitBreaker } /** * Get state for a specific instance */ getState(instanceId: string): CircuitState { return this.circuitBreaker.getState(instanceId) } /** * Get stats for a specific instance */ getStats(instanceId: string): CircuitBreakerStats { return this.circuitBreaker.getStats(instanceId) } /** * Get stats for all tracked instances */ getAllStats(): Map { return this.circuitBreaker.getAllStats() } /** * Reset circuit for an instance */ reset(instanceId: string): void { this.circuitBreaker.reset(instanceId) } /** * Force circuit open for an instance */ forceOpen(instanceId: string): void { this.circuitBreaker.forceOpen(instanceId) } } // ============================================================================ // Factory Functions // ============================================================================ /** * Create a new CircuitBreaker instance * * @example * ```typescript * const cb = createCircuitBreaker({ * failureThreshold: 5, * resetTimeoutMs: 30000, * }) * ``` */ export function createCircuitBreaker(config?: CircuitBreakerConfig): CircuitBreaker { return new CircuitBreaker(config) } /** * Create a new DOCircuitBreakerWrapper instance * * @example * ```typescript * const wrapper = createDOCircuitBreakerWrapper({ * failureThreshold: 3, * fallbackResponse: () => new Response('Service unavailable', { status: 503 }), * }) * ``` */ export function createDOCircuitBreakerWrapper(config?: DOCircuitBreakerConfig): DOCircuitBreakerWrapper { return new DOCircuitBreakerWrapper(config) }