okta-mcp-server
Version:
Model Context Protocol (MCP) server for Okta API operations with support for bulk operations and caching
375 lines • 13.1 kB
JavaScript
/**
* Okta-specific Circuit Breaker implementation with smart retry logic
* Simplified version that handles errors correctly
*/
import { EventEmitter } from 'events';
import { CircuitState, } from './types.js';
/**
* Okta-specific error codes and their handling strategies
*/
export var OktaErrorCode;
(function (OktaErrorCode) {
// Rate limit - should wait and retry
OktaErrorCode[OktaErrorCode["RATE_LIMIT"] = 429] = "RATE_LIMIT";
// Server errors - count as failures
OktaErrorCode[OktaErrorCode["BAD_GATEWAY"] = 502] = "BAD_GATEWAY";
OktaErrorCode[OktaErrorCode["SERVICE_UNAVAILABLE"] = 503] = "SERVICE_UNAVAILABLE";
OktaErrorCode[OktaErrorCode["GATEWAY_TIMEOUT"] = 504] = "GATEWAY_TIMEOUT";
// Client errors - should not trigger circuit
OktaErrorCode[OktaErrorCode["UNAUTHORIZED"] = 401] = "UNAUTHORIZED";
OktaErrorCode[OktaErrorCode["FORBIDDEN"] = 403] = "FORBIDDEN";
OktaErrorCode[OktaErrorCode["NOT_FOUND"] = 404] = "NOT_FOUND";
// Success codes
OktaErrorCode[OktaErrorCode["OK"] = 200] = "OK";
OktaErrorCode[OktaErrorCode["CREATED"] = 201] = "CREATED";
OktaErrorCode[OktaErrorCode["NO_CONTENT"] = 204] = "NO_CONTENT";
})(OktaErrorCode || (OktaErrorCode = {}));
/**
* Simple Okta-specific circuit breaker implementation
*/
export class OktaCircuitBreaker {
state = CircuitState.CLOSED;
failures = 0;
lastFailureTime;
nextAttempt = 0;
cache;
options;
eventEmitter;
// Stats
totalRequests = 0;
totalFailures = 0;
totalSuccesses = 0;
totalRejections = 0;
constructor(options) {
this.options = {
failureThreshold: options.failureThreshold,
resetTimeout: options.resetTimeout,
timeout: options.timeout ?? 0,
name: options.name ?? 'OktaCircuitBreaker',
cache: options.cache,
cacheTTL: options.cacheTTL,
enableCacheFallback: options.enableCacheFallback ?? true,
maxRetries: options.maxRetries ?? 3,
initialRetryDelay: options.initialRetryDelay ?? 1000,
maxRetryDelay: options.maxRetryDelay ?? 30000,
retryMultiplier: options.retryMultiplier ?? 2,
useJitter: options.useJitter ?? true,
};
this.cache = options.cache;
this.eventEmitter = new EventEmitter();
this.eventEmitter.setMaxListeners(0);
}
/**
* Execute a function with Okta-specific retry logic
*/
async execute(fn, ...args) {
return this.executeWithRetry(fn, args, 0);
}
/**
* Execute with exponential backoff retry for rate limits
*/
async executeWithRetry(fn, args, attempt) {
this.totalRequests++;
// Check if circuit is open
if (this.isOpen()) {
this.totalRejections++;
if (this.options.enableCacheFallback && this.cache) {
try {
return await this.cacheFallback(...args);
}
catch (cacheError) {
// Fall through to throw circuit open error
}
}
throw new Error(`Circuit breaker is OPEN for ${this.options.name}`);
}
try {
// Execute the function
const result = await fn(...args);
// Record success
this.recordSuccess();
return result;
}
catch (error) {
const oktaError = error;
// Determine if this is a failure
const isFailure = this.isOktaFailure(oktaError);
if (isFailure) {
this.recordFailure();
}
// Handle rate limit errors with smart retry
if (this.isRateLimitError(oktaError) && attempt < this.options.maxRetries) {
const delay = this.calculateRetryDelay(oktaError, attempt);
// Emit retry event
this.eventEmitter.emit('okta-retry', {
error: oktaError,
attempt: attempt + 1,
delay,
remaining: oktaError.headers?.['x-rate-limit-remaining'],
reset: oktaError.headers?.['x-rate-limit-reset'],
});
// Wait before retry
await this.sleep(delay);
// Retry the operation
return this.executeWithRetry(fn, args, attempt + 1);
}
// Always throw the original error
throw error;
}
}
/**
* Record a successful execution
*/
recordSuccess() {
this.totalSuccesses++;
this.failures = 0;
if (this.state === CircuitState.HALF_OPEN) {
this.transitionTo(CircuitState.CLOSED);
}
}
/**
* Record a failed execution
*/
recordFailure() {
this.totalFailures++;
this.failures++;
this.lastFailureTime = Date.now();
if (this.state === CircuitState.HALF_OPEN) {
this.transitionTo(CircuitState.OPEN);
}
else if (this.state === CircuitState.CLOSED &&
this.failures >= this.options.failureThreshold) {
this.transitionTo(CircuitState.OPEN);
}
}
/**
* Transition to a new state
*/
transitionTo(newState) {
const oldState = this.state;
this.state = newState;
if (newState === CircuitState.OPEN) {
this.nextAttempt = Date.now() + this.options.resetTimeout;
}
this.eventEmitter.emit('state-change', oldState, newState, this.getStats());
}
/**
* Determine if an error should count as a circuit breaker failure
*/
isOktaFailure(error) {
const oktaError = error;
// Network errors always count as failures
if (this.isNetworkError(oktaError)) {
return true;
}
// Check status code if available
if (oktaError.status) {
// Rate limits should not count as failures
if (oktaError.status === OktaErrorCode.RATE_LIMIT) {
return false;
}
// Client errors (4xx) except 429 should not trigger circuit
if (oktaError.status >= 400 && oktaError.status < 500) {
return false;
}
// Server errors (5xx) count as failures
if (oktaError.status >= 500) {
return true;
}
}
// Default to counting as failure
return true;
}
/**
* Check if error is a rate limit error
*/
isRateLimitError(error) {
return (error.status === OktaErrorCode.RATE_LIMIT ||
error.code === 'E0000047' || // Okta rate limit error code
error.errorCode === 'E0000047');
}
/**
* Check if error is a network error
*/
isNetworkError(error) {
return (error.message?.includes('ECONNREFUSED') ||
error.message?.includes('ETIMEDOUT') ||
error.message?.includes('ENOTFOUND') ||
error.message?.includes('ENETUNREACH') ||
error.message?.includes('EAI_AGAIN') ||
error.message?.includes('fetch failed'));
}
/**
* Calculate retry delay with exponential backoff and jitter
*/
calculateRetryDelay(error, attempt) {
// Check if server provided retry-after header
if (error.headers?.['retry-after']) {
const retryAfter = parseInt(error.headers['retry-after'], 10);
if (!isNaN(retryAfter)) {
return retryAfter * 1000; // Convert to milliseconds
}
}
// Check rate limit reset time
if (error.headers?.['x-rate-limit-reset']) {
const resetTime = parseInt(error.headers['x-rate-limit-reset'], 10);
if (!isNaN(resetTime)) {
const now = Math.floor(Date.now() / 1000);
const waitTime = Math.max(0, resetTime - now) * 1000;
if (waitTime > 0 && waitTime < this.options.maxRetryDelay) {
return waitTime;
}
}
}
// Calculate exponential backoff
let delay = Math.min(this.options.initialRetryDelay * Math.pow(this.options.retryMultiplier, attempt), this.options.maxRetryDelay);
// Add jitter to prevent thundering herd
if (this.options.useJitter) {
delay = delay * (0.5 + Math.random() * 0.5);
}
return Math.floor(delay);
}
/**
* Cache fallback for read operations
*/
async cacheFallback(...args) {
if (!this.cache) {
throw new Error('Circuit breaker is OPEN and no cache available');
}
// Generate cache key from arguments
const cacheKey = `okta-cb:${JSON.stringify(args)}`;
// Try to get from cache
const cachedValue = await this.cache.get(cacheKey);
if (cachedValue !== undefined) {
// Emit cache hit event
this.eventEmitter.emit('okta-cache-fallback', {
key: cacheKey,
hit: true,
});
return cachedValue;
}
// Emit cache miss event
this.eventEmitter.emit('okta-cache-fallback', {
key: cacheKey,
hit: false,
});
throw new Error('Circuit breaker is OPEN and no cached data available');
}
/**
* Sleep for specified milliseconds
*/
sleep(ms) {
if (ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ICircuitBreaker interface implementation
getState() {
return this.state;
}
getStats() {
return {
state: this.state,
failures: this.totalFailures,
successes: this.totalSuccesses,
rejections: this.totalRejections,
totalRequests: this.totalRequests,
consecutiveFailures: this.failures,
consecutiveSuccesses: 0,
lastFailureTime: this.lastFailureTime,
lastSuccessTime: undefined,
nextAttempt: this.nextAttempt > Date.now() ? this.nextAttempt : undefined,
failureRate: this.totalRequests > 0 ? (this.totalFailures / this.totalRequests) * 100 : 0,
rollingCountFailure: 0,
rollingCountSuccess: 0,
rollingCountTimeout: 0,
rollingCountRejected: 0,
};
}
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.failures = 0;
this.lastFailureTime = undefined;
this.nextAttempt = 0;
this.totalRequests = 0;
this.totalFailures = 0;
this.totalSuccesses = 0;
this.totalRejections = 0;
}
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() {
return this.getStats();
}
}
/**
* Factory function to create an Okta circuit breaker
*/
export function createOktaCircuitBreaker(options) {
return new OktaCircuitBreaker(options);
}
/**
* Wrapper function to wrap any async function with Okta circuit breaker
*/
export function withOktaCircuitBreaker(fn, options) {
const circuitBreaker = createOktaCircuitBreaker({
failureThreshold: 5,
resetTimeout: 60000,
name: fn.name || 'okta-wrapped-function',
...options,
});
return ((...args) => circuitBreaker.execute(fn, ...args));
}
/**
* Higher-order function to create a cached Okta API method
*/
export function createCachedOktaMethod(method, cache, options) {
const wrappedMethod = withOktaCircuitBreaker(method, {
cache,
enableCacheFallback: true,
...options,
});
// Create a function that also caches successful results
return (async (...args) => {
const cacheKey = `okta-method:${method.name}:${JSON.stringify(args)}`;
try {
// Execute through circuit breaker
const result = await wrappedMethod(...args);
// Cache successful results for fallback
await cache.set(cacheKey, result, { ttl: 300 }); // 5 minutes TTL
return result;
}
catch (error) {
// Circuit breaker will handle fallback
throw error;
}
});
}
//# sourceMappingURL=okta-circuit-breaker.js.map