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
JavaScript
/**
* 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