UNPKG

okta-mcp-server

Version:

Model Context Protocol (MCP) server for Okta API operations with support for bulk operations and caching

251 lines 9.07 kB
/** * Circuit Breaker implementation for fault tolerance */ import { EventEmitter } from 'events'; import { CircuitState, } from './types.js'; import { RollingWindowImpl } from './rolling-window.js'; export class CircuitBreaker { state = CircuitState.CLOSED; options; eventEmitter; rollingWindow; nextAttempt = 0; consecutiveFailures = 0; consecutiveSuccesses = 0; lastFailureTime; lastSuccessTime; // Metrics totalRequests = 0; totalFailures = 0; totalSuccesses = 0; totalRejections = 0; constructor(options) { this.options = this.mergeWithDefaults(options); this.eventEmitter = new EventEmitter(); this.rollingWindow = new RollingWindowImpl(this.options.rollingWindowSize, this.options.rollingWindowBuckets); // Set max listeners to prevent warnings this.eventEmitter.setMaxListeners(0); } async execute(fn, ...args) { this.totalRequests++; const startTime = Date.now(); // Check if circuit is open if (this.isOpen()) { this.handleRejection('Circuit breaker is OPEN'); // Try fallback if available if (this.options.fallback) { this.emitEvent('fallback', args); return this.options.fallback(...args); } throw new Error(`Circuit breaker is OPEN for ${this.options.name || 'unknown'}`); } try { // Execute with timeout const result = await this.executeWithTimeout(fn, args); this.handleSuccess(result, Date.now() - startTime); return result; } catch (error) { this.handleFailure(error, Date.now() - startTime); // Try fallback on failure if (this.options.fallback) { this.emitEvent('fallback', args); return this.options.fallback(...args); } throw error; } } getState() { return this.state; } getStats() { const windowCounts = this.rollingWindow.getCounts(); return { state: this.state, failures: this.totalFailures, successes: this.totalSuccesses, rejections: this.totalRejections, totalRequests: this.totalRequests, consecutiveFailures: this.consecutiveFailures, consecutiveSuccesses: this.consecutiveSuccesses, lastFailureTime: this.lastFailureTime, lastSuccessTime: this.lastSuccessTime, nextAttempt: this.nextAttempt > Date.now() ? this.nextAttempt : undefined, failureRate: this.totalRequests > 0 ? (this.totalFailures / this.totalRequests) * 100 : 0, rollingCountFailure: windowCounts.failure, rollingCountSuccess: windowCounts.success, rollingCountTimeout: windowCounts.timeout, rollingCountRejected: windowCounts.rejected, }; } open() { if (this.state !== CircuitState.OPEN) { this.transitionTo(CircuitState.OPEN); } } close() { if (this.state !== CircuitState.CLOSED) { this.transitionTo(CircuitState.CLOSED); } } reset() { this.state = CircuitState.CLOSED; this.nextAttempt = 0; this.consecutiveFailures = 0; this.consecutiveSuccesses = 0; this.lastFailureTime = undefined; this.lastSuccessTime = undefined; this.totalRequests = 0; this.totalFailures = 0; this.totalSuccesses = 0; this.totalRejections = 0; this.rollingWindow.reset(); if (this.options.emitEvents) { this.emitEvent('health-check', this.getStats()); } } isOpen() { if (this.state === CircuitState.CLOSED) { return false; } if (this.state === CircuitState.OPEN) { // Check if we should transition to HALF_OPEN if (Date.now() >= this.nextAttempt) { this.transitionTo(CircuitState.HALF_OPEN); return false; } return true; } // HALF_OPEN state allows requests return false; } getEventEmitter() { return this.eventEmitter; } async healthCheck() { const stats = this.getStats(); this.emitEvent('health-check', stats); return stats; } async executeWithTimeout(fn, args) { if (!this.options.timeout) { return fn(...args); } return Promise.race([ fn(...args), new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timeout after ${this.options.timeout}ms`)), this.options.timeout)), ]); } handleSuccess(result, latency) { this.totalSuccesses++; this.consecutiveSuccesses++; this.consecutiveFailures = 0; this.lastSuccessTime = Date.now(); this.rollingWindow.recordSuccess(); this.emitEvent('success', result, latency); if (this.state === CircuitState.HALF_OPEN) { this.emitEvent('half-open-success', result); if (this.consecutiveSuccesses >= this.options.successThreshold) { this.transitionTo(CircuitState.CLOSED); } } } handleFailure(error, latency) { const isTimeout = error.message.includes('timeout'); const isFailure = this.options.isFailure ? this.options.isFailure(error) : true; if (!isFailure) { // If not considered a failure, treat as success this.handleSuccess(undefined, latency); return; } this.totalFailures++; this.consecutiveFailures++; this.consecutiveSuccesses = 0; this.lastFailureTime = Date.now(); if (isTimeout) { this.rollingWindow.recordTimeout(); this.emitEvent('timeout', latency); } else { this.rollingWindow.recordFailure(); this.emitEvent('failure', error, latency); } if (this.state === CircuitState.HALF_OPEN) { this.emitEvent('half-open-failure', error); this.transitionTo(CircuitState.OPEN); } else if (this.state === CircuitState.CLOSED) { this.checkThresholds(); } } handleRejection(reason) { this.totalRejections++; this.rollingWindow.recordRejection(); this.emitEvent('reject', reason); } checkThresholds() { const counts = this.rollingWindow.getCounts(); // Check failure threshold if (this.consecutiveFailures >= this.options.failureThreshold) { this.transitionTo(CircuitState.OPEN); return; } // Check percentage threshold if volume threshold is met if (this.options.failurePercentageThreshold !== undefined && this.options.volumeThreshold !== undefined && counts.total >= this.options.volumeThreshold && counts.errorPercentage >= this.options.failurePercentageThreshold) { this.transitionTo(CircuitState.OPEN); } } transitionTo(newState) { const oldState = this.state; this.state = newState; // Reset counters on state change this.consecutiveFailures = 0; this.consecutiveSuccesses = 0; if (newState === CircuitState.OPEN) { this.nextAttempt = Date.now() + this.options.resetTimeout; } const stats = this.getStats(); this.emitEvent('state-change', oldState, newState, stats); } emitEvent(event, ...args) { if (this.options.emitEvents) { this.eventEmitter.emit(event, ...args); } } mergeWithDefaults(options) { return { failureThreshold: options.failureThreshold, failurePercentageThreshold: options.failurePercentageThreshold ?? 50, volumeThreshold: options.volumeThreshold ?? 10, resetTimeout: options.resetTimeout, rollingWindowSize: options.rollingWindowSize ?? 60000, rollingWindowBuckets: options.rollingWindowBuckets ?? 10, successThreshold: options.successThreshold ?? 5, timeout: options.timeout ?? 3000, name: options.name ?? 'CircuitBreaker', emitEvents: options.emitEvents ?? true, isFailure: options.isFailure ?? (() => true), fallback: options.fallback ?? undefined, }; } } /** * Factory function to create a circuit breaker */ export function createCircuitBreaker(options) { return new CircuitBreaker(options); } /** * Wrapper function to wrap any async function with a circuit breaker */ export function withCircuitBreaker(fn, options) { const circuitBreaker = createCircuitBreaker({ name: fn.name || 'wrapped-function', ...options, }); return ((...args) => circuitBreaker.execute(fn, ...args)); } //# sourceMappingURL=circuit-breaker.js.map