UNPKG

digital-samba-mcp-server

Version:

Digital Samba MCP Server - Model Context Protocol server for Digital Samba's video conferencing API

660 lines 26 kB
/** * Circuit Breaker Pattern Implementation * * This module provides a circuit breaker implementation for handling API calls * in a fault-tolerant manner. The circuit breaker pattern prevents cascading failures * by breaking the circuit when a service is failing, and only attempting to restore * the connection after a specified timeout. * * Key features: * - Three states: CLOSED (normal operation), OPEN (failing, no requests), HALF_OPEN (testing recovery) * - Configurable thresholds for failure count, timeouts, and success count for reset * - Event hooks for state changes and failures * - Support for fallback handlers when the circuit is open * - Metrics integration for monitoring circuit state and events * * @module circuit-breaker * @author Digital Samba Team * @version 0.1.0 */ // Node.js modules import { EventEmitter } from 'events'; // Local modules import logger from './logger.js'; import { ApiRequestError } from './errors.js'; /** * Circuit state enumeration * * CLOSED: Normal operation, requests pass through * OPEN: Circuit is broken, no requests pass through * HALF_OPEN: Testing if service has recovered */ export var CircuitState; (function (CircuitState) { CircuitState["CLOSED"] = "CLOSED"; CircuitState["OPEN"] = "OPEN"; CircuitState["HALF_OPEN"] = "HALF_OPEN"; })(CircuitState || (CircuitState = {})); /** * Circuit breaker implementation for handling API calls * * The CircuitBreaker class implements the circuit breaker pattern to prevent * cascading failures when a service is experiencing issues. It monitors * failures and opens the circuit when a threshold is reached, allowing * the service time to recover. * * @class CircuitBreaker * @example * // Create a circuit breaker for a specific API endpoint * const circuitBreaker = new CircuitBreaker({ * name: 'listRooms', * failureThreshold: 3, * resetTimeout: 10000, * successThreshold: 2, * fallback: async () => ({ data: [], total_count: 0, length: 0, map: () => [] }) * }); * * // Protect a function call with the circuit breaker * const rooms = await circuitBreaker.exec(() => apiClient.listRooms()); */ export class CircuitBreaker extends EventEmitter { /** * Creates a new CircuitBreaker instance * * @constructor * @param {CircuitBreakerOptions} options - Configuration options for the circuit breaker */ constructor(options) { super(); this.state = CircuitState.CLOSED; this.failureCount = 0; this.successCount = 0; this.lastError = null; this.nextAttempt = Date.now(); this.name = options.name; this.failureThreshold = options.failureThreshold ?? 5; this.resetTimeout = options.resetTimeout ?? 30000; this.successThreshold = options.successThreshold ?? 2; this.requestTimeout = options.requestTimeout; this.initialRequestTimeout = options.initialRequestTimeout ?? 30000; // Default to 30 seconds for initial request this.isFailure = options.isFailure ?? (() => true); this.fallback = options.fallback; // Log the circuit breaker creation logger.debug(`Circuit breaker created: ${this.name}`, { circuit: this.name, failureThreshold: this.failureThreshold, resetTimeout: this.resetTimeout, successThreshold: this.successThreshold, requestTimeout: this.requestTimeout }); // Track circuit created metric if metrics module is available this.updateMetrics('created'); } /** * Get the current state of the circuit * * @returns {CircuitState} The current circuit state */ getState() { return this.state; } /** * Get the name of the circuit * * @returns {string} The circuit name */ getName() { return this.name; } /** * Get the last error that occurred * * @returns {Error | null} The last error or null if no errors have occurred */ getLastError() { return this.lastError; } /** * Execute a function with circuit breaker protection * * This method wraps the provided function with circuit breaker logic. * If the circuit is open, the function will not be called and an error will be thrown * (or the fallback will be used if provided). In CLOSED or HALF_OPEN states, * the function will be called and the result will be monitored for success or failure. * * @template T - The return type of the function * @template Args - The argument types of the function * @param {() => Promise<T>} fn - The function to protect * @param {Args} args - Arguments to pass to the function (as an array) * @param {boolean} [forceNoTimeout=false] - If true, disables the timeout for this call * @param {boolean} [isInitialization=false] - If true, treats this as an initialization request with special handling * @returns {Promise<T>} The result of the function or fallback * @throws {Error} If the circuit is open and no fallback is provided * @example * // Protect an API call * const result = await circuitBreaker.exec( * async () => { return await fetch('https://api.example.com/data'); }, * [] // No args * ); */ async exec(fn, args = [], forceNoTimeout = false, isInitialization = false) { // Debug initialization requests if enabled const debugInitialization = process.env.DEBUG_INITIALIZATION === 'true'; if (isInitialization && debugInitialization) { logger.info(`Executing initialization request for circuit: ${this.name}`, { circuit: this.name, state: this.state, forceNoTimeout }); } // Check if the circuit is open if (this.state === CircuitState.OPEN) { // If we passed the reset timeout, transition to half-open if (Date.now() > this.nextAttempt) { this.toHalfOpen(); } else { // Circuit is still open, use fallback or throw error if (this.fallback) { logger.debug(`Circuit ${this.name} is OPEN, using fallback`, { circuit: this.name, nextAttempt: new Date(this.nextAttempt).toISOString() }); return this.fallback(args); } // No fallback available, throw error logger.warn(`Circuit ${this.name} is OPEN, rejecting request`, { circuit: this.name, nextAttempt: new Date(this.nextAttempt).toISOString() }); throw new ApiRequestError(`Circuit breaker ${this.name} is OPEN. Last error: ${this.lastError?.message ?? 'Unknown error'}`, { cause: this.lastError ?? undefined }); } } // Execute the function with optional timeout try { let result; // For initialization requests during server startup, we need special handling if (isInitialization) { if (debugInitialization) { logger.info(`Running initialization request with special handling for circuit: ${this.name}`); } // Always force no timeout for initialization result = await fn(); } // If forceNoTimeout is true, skip timeout handling entirely else if (forceNoTimeout) { // No timeout, just execute the function result = await fn(); } else { // If this is the first request and state is CLOSED, use initialRequestTimeout // otherwise use the standard requestTimeout const useTimeout = this.state === CircuitState.CLOSED && this.failureCount === 0 ? this.initialRequestTimeout : this.requestTimeout; // Override with environment variable if set const envInitialTimeout = process.env.INITIAL_REQUEST_TIMEOUT ? parseInt(process.env.INITIAL_REQUEST_TIMEOUT) : undefined; const finalTimeout = envInitialTimeout !== undefined && this.state === CircuitState.CLOSED && this.failureCount === 0 ? envInitialTimeout : useTimeout; // Debug timeout settings if enabled if (process.env.DEBUG_TIMEOUTS === 'true') { logger.debug(`Circuit timeout settings for ${this.name}:`, { circuit: this.name, useTimeout, envInitialTimeout, finalTimeout, state: this.state, failureCount: this.failureCount }); } if (finalTimeout !== undefined) { // Use Promise.race to implement timeout const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Request timeout after ${finalTimeout}ms`)); }, finalTimeout); }); // Race the function execution against the timeout result = await Promise.race([ fn(), timeoutPromise ]); } else { // No timeout, just execute the function result = await fn(); } } // Success - handle based on current state this.handleSuccess(); // Log successful initialization if (isInitialization && debugInitialization) { logger.info(`Initialization request completed successfully for circuit: ${this.name}`); } return result; } catch (error) { // Log initialization failures with more details if (isInitialization && debugInitialization) { logger.error(`Initialization request failed for circuit: ${this.name}`, { circuit: this.name, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); } // Determine if this error should count as a circuit failure if (this.isFailure(error)) { this.handleFailure(error instanceof Error ? error : new Error(String(error))); // Re-throw the original error throw error; } else { // Not a circuit failure, but still an error logger.debug(`Error in circuit ${this.name} not counted as failure`, { circuit: this.name, error: error instanceof Error ? error.message : String(error) }); // Re-throw the original error throw error; } } } /** * Handle a successful execution * * @private */ handleSuccess() { if (this.state === CircuitState.HALF_OPEN) { // In half-open state, count successes toward closing the circuit this.successCount++; logger.debug(`Circuit ${this.name} success in HALF_OPEN state (${this.successCount}/${this.successThreshold})`, { circuit: this.name, successCount: this.successCount, successThreshold: this.successThreshold }); this.updateMetrics('success'); // Check if we've reached the threshold to close the circuit if (this.successCount >= this.successThreshold) { this.toClosed(); } } else if (this.state === CircuitState.CLOSED) { // In closed state, reset the failure count on success this.failureCount = 0; this.lastError = null; this.updateMetrics('success'); } } /** * Handle a failure * * @private * @param {Error} error - The error that occurred */ handleFailure(error) { // Store the last error this.lastError = error; if (this.state === CircuitState.CLOSED) { // In closed state, count failures toward opening the circuit this.failureCount++; logger.debug(`Circuit ${this.name} failure in CLOSED state (${this.failureCount}/${this.failureThreshold})`, { circuit: this.name, failureCount: this.failureCount, failureThreshold: this.failureThreshold, error: error.message }); this.updateMetrics('failure'); // Check if we've reached the threshold to open the circuit if (this.failureCount >= this.failureThreshold) { this.toOpen(); } } else if (this.state === CircuitState.HALF_OPEN) { // Any failure in half-open state immediately opens the circuit logger.debug(`Circuit ${this.name} failure in HALF_OPEN state, opening circuit`, { circuit: this.name, error: error.message }); this.updateMetrics('failure'); this.toOpen(); } } /** * Transition the circuit to the OPEN state * * @private */ toOpen() { if (this.state !== CircuitState.OPEN) { const previousState = this.state; this.state = CircuitState.OPEN; this.nextAttempt = Date.now() + this.resetTimeout; this.successCount = 0; logger.info(`Circuit ${this.name} transitioned from ${previousState} to OPEN`, { circuit: this.name, previousState, nextAttempt: new Date(this.nextAttempt).toISOString(), lastError: this.lastError?.message }); // Emit state change event this.emit('open', { name: this.name, previousState, lastError: this.lastError }); this.updateMetrics('state_change', { state: 'OPEN' }); } } /** * Transition the circuit to the HALF_OPEN state * * @private */ toHalfOpen() { if (this.state !== CircuitState.HALF_OPEN) { const previousState = this.state; this.state = CircuitState.HALF_OPEN; this.successCount = 0; logger.info(`Circuit ${this.name} transitioned from ${previousState} to HALF_OPEN`, { circuit: this.name, previousState }); // Emit state change event this.emit('half-open', { name: this.name, previousState }); this.updateMetrics('state_change', { state: 'HALF_OPEN' }); } } /** * Transition the circuit to the CLOSED state * * @private */ toClosed() { if (this.state !== CircuitState.CLOSED) { const previousState = this.state; this.state = CircuitState.CLOSED; this.failureCount = 0; this.successCount = 0; this.lastError = null; logger.info(`Circuit ${this.name} transitioned from ${previousState} to CLOSED`, { circuit: this.name, previousState }); // Emit state change event this.emit('close', { name: this.name, previousState }); this.updateMetrics('state_change', { state: 'CLOSED' }); } } /** * Reset the circuit to the CLOSED state regardless of current state * * This method can be called externally to force the circuit back to normal operation. * This is useful for manual intervention after investigating and resolving an issue. */ reset() { logger.info(`Circuit ${this.name} manually reset to CLOSED`, { circuit: this.name, previousState: this.state }); this.toClosed(); this.emit('reset', { name: this.name }); this.updateMetrics('reset'); } /** * Force the circuit to the OPEN state * * This method can be called externally to force the circuit to the OPEN state. * This is useful for pre-emptively stopping traffic to a service that is known to be down. * * @param {Error} [error] - Optional error to store as the last error */ trip(error) { if (error) { this.lastError = error; } logger.info(`Circuit ${this.name} manually tripped to OPEN`, { circuit: this.name, previousState: this.state, error: error?.message }); this.toOpen(); this.emit('trip', { name: this.name, error }); this.updateMetrics('trip'); } /** * Update metrics for the circuit breaker * * This method attempts to update Prometheus metrics if the metrics module is available. * If the metrics module cannot be imported, the method silently ignores the error. * * @private * @param {string} event - The event type ('created', 'success', 'failure', 'state_change', 'reset', 'trip') * @param {Record<string, any>} [labels] - Additional labels for the metric */ async updateMetrics(event, labels) { try { const metricsRegistry = await import('./metrics.js').then(m => m.default); const baseLabels = { circuit: this.name, ...labels }; switch (event) { case 'created': // New circuit breaker created metricsRegistry.circuitBreakersTotal.inc(1); metricsRegistry.circuitBreakerStateInfo.set({ ...baseLabels, state: this.state }, 1); break; case 'success': // Successful request through the circuit metricsRegistry.circuitBreakerSuccess.inc(baseLabels); break; case 'failure': // Failed request through the circuit metricsRegistry.circuitBreakerFailures.inc(baseLabels); break; case 'state_change': // Circuit state changed if (labels?.state) { // Set the new state to 1 and all other states to 0 const states = Object.values(CircuitState); for (const state of states) { const value = state === labels.state ? 1 : 0; metricsRegistry.circuitBreakerStateInfo.set({ circuit: this.name, state }, value); } } break; case 'reset': // Circuit manually reset metricsRegistry.circuitBreakerResets.inc(baseLabels); metricsRegistry.circuitBreakerStateInfo.set({ ...baseLabels, state: CircuitState.CLOSED }, 1); // Set other states to 0 metricsRegistry.circuitBreakerStateInfo.set({ ...baseLabels, state: CircuitState.OPEN }, 0); metricsRegistry.circuitBreakerStateInfo.set({ ...baseLabels, state: CircuitState.HALF_OPEN }, 0); break; case 'trip': // Circuit manually tripped metricsRegistry.circuitBreakerTrips.inc(baseLabels); metricsRegistry.circuitBreakerStateInfo.set({ ...baseLabels, state: CircuitState.OPEN }, 1); // Set other states to 0 metricsRegistry.circuitBreakerStateInfo.set({ ...baseLabels, state: CircuitState.CLOSED }, 0); metricsRegistry.circuitBreakerStateInfo.set({ ...baseLabels, state: CircuitState.HALF_OPEN }, 0); break; } } catch (error) { // Metrics module might not be available, or other error occurred // This is non-critical, so just log at debug level logger.debug(`Error updating circuit breaker metrics: ${error instanceof Error ? error.message : String(error)}`); } } } /** * Circuit breaker registry to manage multiple circuit breakers * * This class provides a registry for managing multiple circuit breakers * with convenient methods for creation, retrieval, and management. * * @class CircuitBreakerRegistry * @example * // Get the global registry * const registry = CircuitBreakerRegistry.getInstance(); * * // Create a new circuit breaker * const listRoomsCircuit = registry.create({ * name: 'listRooms', * failureThreshold: 3 * }); * * // Get an existing circuit breaker * const circuit = registry.get('listRooms'); * * // Execute a function with the circuit breaker * const rooms = await circuit.exec(() => apiClient.listRooms()); */ export class CircuitBreakerRegistry extends EventEmitter { /** * Private constructor to enforce singleton pattern * * @private */ constructor() { super(); // Initialize EventEmitter this.circuits = new Map(); } /** * Get the singleton instance of the registry * * @returns {CircuitBreakerRegistry} The singleton registry instance */ static getInstance() { if (!CircuitBreakerRegistry.instance) { CircuitBreakerRegistry.instance = new CircuitBreakerRegistry(); } return CircuitBreakerRegistry.instance; } /** * Create a new circuit breaker and add it to the registry * * @param {CircuitBreakerOptions} options - Options for the new circuit breaker * @returns {CircuitBreaker} The newly created circuit breaker * @throws {Error} If a circuit breaker with the same name already exists */ create(options) { if (this.circuits.has(options.name)) { logger.warn(`Circuit breaker with name ${options.name} already exists`); return this.circuits.get(options.name); } const circuitBreaker = new CircuitBreaker(options); this.circuits.set(options.name, circuitBreaker); logger.debug(`Added circuit breaker to registry: ${options.name}`, { circuit: options.name, totalCircuits: this.circuits.size }); // Emit created event for the new circuit breaker this.emit('created', circuitBreaker); return circuitBreaker; } /** * Get a circuit breaker from the registry * * @param {string} name - The name of the circuit breaker to retrieve * @returns {CircuitBreaker | undefined} The circuit breaker or undefined if not found */ get(name) { return this.circuits.get(name); } /** * Get or create a circuit breaker * * If a circuit breaker with the given name exists, it will be returned. * Otherwise, a new circuit breaker will be created with the provided options. * * @param {CircuitBreakerOptions} options - Options for the circuit breaker * @returns {CircuitBreaker} The existing or newly created circuit breaker */ getOrCreate(options) { const existing = this.circuits.get(options.name); if (existing) { return existing; } return this.create(options); } /** * Remove a circuit breaker from the registry * * @param {string} name - The name of the circuit breaker to remove * @returns {boolean} True if the circuit breaker was removed, false if it was not found */ remove(name) { const removed = this.circuits.delete(name); if (removed) { logger.debug(`Removed circuit breaker from registry: ${name}`, { circuit: name, totalCircuits: this.circuits.size }); } return removed; } /** * Get all circuit breakers in the registry * * @returns {CircuitBreaker[]} An array of all circuit breakers */ getAll() { return Array.from(this.circuits.values()); } /** * Get the count of circuit breakers in the registry * * @returns {number} The number of circuit breakers */ getCount() { return this.circuits.size; } /** * Reset all circuit breakers to the CLOSED state * * This is useful for system restarts or after resolving a widespread issue. */ resetAll() { for (const circuit of this.circuits.values()) { circuit.reset(); } logger.info(`Reset all ${this.circuits.size} circuit breakers to CLOSED state`); } } // Export a singleton instance of the registry export const circuitBreakerRegistry = CircuitBreakerRegistry.getInstance(); export default circuitBreakerRegistry; //# sourceMappingURL=circuit-breaker.js.map