digital-samba-mcp-server
Version:
Digital Samba MCP Server - Model Context Protocol server for Digital Samba's video conferencing API
660 lines • 26 kB
JavaScript
/**
* 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