Skip to main content
Glama

Optimizely DXP MCP Server

by JaxonDigital
rate-limiter.js18.9 kB
/** * Rate Limiter Module * Handles API rate limiting and throttling for Optimizely DXP operations * Part of Jaxon Digital Optimizely DXP MCP Server */ const fs = require('fs'); const path = require('path'); const os = require('os'); class RateLimiter { constructor(options = {}) { this.options = { // Default limits (per project per minute) maxRequestsPerMinute: options.maxRequestsPerMinute || 60, maxRequestsPerHour: options.maxRequestsPerHour || 1000, // Burst allowance burstAllowance: options.burstAllowance || 10, // Backoff settings initialBackoff: options.initialBackoff || 1000, // 1 second maxBackoff: options.maxBackoff || 30000, // 30 seconds backoffMultiplier: options.backoffMultiplier || 2, // Storage persistState: options.persistState !== false, storageDir: options.storageDir || path.join(os.tmpdir(), 'optimizely-mcp-rate-limiter'), // Debugging debug: options.debug || process.env.DEBUG === 'true' }; // Rate limit tracking per project this.projectLimits = new Map(); // Global limits (cross-project) this.globalLimits = { requests: [], lastReset: Date.now() }; // 429 response tracking this.throttleState = new Map(); // Initialize storage this.initializeStorage(); // Load persisted state if (this.options.persistState) { this.loadState(); } // Periodic cleanup this.cleanupInterval = setInterval(() => { this.cleanup(); }, 60000); // Every minute } /** * Initialize storage directory */ initializeStorage() { if (!this.options.persistState) return; try { if (!fs.existsSync(this.options.storageDir)) { fs.mkdirSync(this.options.storageDir, { recursive: true }); } } catch (error) { if (this.options.debug) { console.error('Rate limiter storage init failed:', error.message); } } } /** * Load persisted rate limit state */ loadState() { try { const stateFile = path.join(this.options.storageDir, 'rate-limits.json'); if (fs.existsSync(stateFile)) { const data = JSON.parse(fs.readFileSync(stateFile, 'utf8')); // Load project limits (only recent ones) const cutoff = Date.now() - (60 * 60 * 1000); // 1 hour ago if (data.projectLimits) { Object.entries(data.projectLimits).forEach(([projectId, limits]) => { const recentRequests = limits.requests.filter(r => r.timestamp > cutoff); if (recentRequests.length > 0) { this.projectLimits.set(projectId, { ...limits, requests: recentRequests }); } }); } // Load throttle states if (data.throttleState) { Object.entries(data.throttleState).forEach(([projectId, state]) => { if (state.retryAfter && state.retryAfter > Date.now()) { this.throttleState.set(projectId, state); } }); } } } catch (error) { if (this.options.debug) { console.error('Failed to load rate limiter state:', error.message); } } } /** * Save rate limit state to disk */ saveState() { if (!this.options.persistState) return; try { const stateFile = path.join(this.options.storageDir, 'rate-limits.json'); const data = { projectLimits: Object.fromEntries(this.projectLimits.entries()), throttleState: Object.fromEntries(this.throttleState.entries()), savedAt: Date.now() }; fs.writeFileSync(stateFile, JSON.stringify(data, null, 2)); } catch (error) { if (this.options.debug) { console.error('Failed to save rate limiter state:', error.message); } } } /** * Check if a request is allowed * @param {string} projectId - Project identifier * @param {string} operation - Operation type (for specific limits) * @returns {Object} { allowed: boolean, waitTime: number, reason: string } */ checkRateLimit(projectId, operation = 'api_call') { const now = Date.now(); // Check if we're in a throttled state const throttleStatus = this.checkThrottleState(projectId); if (!throttleStatus.allowed) { return throttleStatus; } // Get or create project limits if (!this.projectLimits.has(projectId)) { this.projectLimits.set(projectId, { requests: [], lastRequest: 0, consecutiveFailures: 0, backoffUntil: 0 }); } const limits = this.projectLimits.get(projectId); // Check backoff period if (limits.backoffUntil > now) { return { allowed: false, waitTime: limits.backoffUntil - now, reason: 'backoff', retryAfter: limits.backoffUntil }; } // Clean old requests const oneHourAgo = now - (60 * 60 * 1000); const oneMinuteAgo = now - (60 * 1000); limits.requests = limits.requests.filter(r => r.timestamp > oneHourAgo); // Count requests in time windows const requestsLastMinute = limits.requests.filter(r => r.timestamp > oneMinuteAgo); const requestsLastHour = limits.requests.length; // Check per-minute limit if (requestsLastMinute.length >= this.options.maxRequestsPerMinute) { const oldestInMinute = Math.min(...requestsLastMinute.map(r => r.timestamp)); const waitTime = (oldestInMinute + 60000) - now; return { allowed: false, waitTime: Math.max(0, waitTime), reason: 'rate_limit_minute', retryAfter: now + waitTime }; } // Check per-hour limit if (requestsLastHour >= this.options.maxRequestsPerHour) { const oldestInHour = Math.min(...limits.requests.map(r => r.timestamp)); const waitTime = (oldestInHour + 3600000) - now; return { allowed: false, waitTime: Math.max(0, waitTime), reason: 'rate_limit_hour', retryAfter: now + waitTime }; } // Check burst protection if (requestsLastMinute.length >= this.options.burstAllowance) { const timeSinceLastRequest = now - limits.lastRequest; if (timeSinceLastRequest < 1000) { // Less than 1 second return { allowed: false, waitTime: 1000 - timeSinceLastRequest, reason: 'burst_protection', retryAfter: now + (1000 - timeSinceLastRequest) }; } } return { allowed: true }; } /** * Record a successful request * @param {string} projectId - Project identifier * @param {string} operation - Operation type */ recordRequest(projectId, operation = 'api_call') { const now = Date.now(); if (!this.projectLimits.has(projectId)) { this.projectLimits.set(projectId, { requests: [], lastRequest: 0, consecutiveFailures: 0, backoffUntil: 0 }); } const limits = this.projectLimits.get(projectId); limits.requests.push({ timestamp: now, operation, success: true }); limits.lastRequest = now; limits.consecutiveFailures = 0; // Reset on success // Save state this.saveState(); if (this.options.debug) { console.error(`Rate limit: Request recorded for ${projectId} (${operation})`); } } /** * Record a rate limit response (429) * @param {string} projectId - Project identifier * @param {Object} response - Rate limit response details */ recordRateLimit(projectId, response = {}) { const now = Date.now(); const retryAfter = this.parseRetryAfter(response.retryAfter) || 60000; // Default 1 minute if (!this.projectLimits.has(projectId)) { this.projectLimits.set(projectId, { requests: [], lastRequest: 0, consecutiveFailures: 0, backoffUntil: 0 }); } const limits = this.projectLimits.get(projectId); limits.consecutiveFailures++; // Set throttle state this.throttleState.set(projectId, { throttledAt: now, retryAfter: now + retryAfter, consecutiveThrottles: (this.throttleState.get(projectId)?.consecutiveThrottles || 0) + 1, lastRetryAfter: retryAfter }); // Increase backoff for repeated failures const backoffTime = Math.min( this.options.initialBackoff * Math.pow(this.options.backoffMultiplier, limits.consecutiveFailures - 1), this.options.maxBackoff ); limits.backoffUntil = now + backoffTime; // Save state this.saveState(); if (this.options.debug) { console.error(`Rate limit: 429 recorded for ${projectId}, retry after ${retryAfter}ms`); } } /** * Record a failed request (for backoff calculation) * @param {string} projectId - Project identifier * @param {Error} error - Error details */ recordFailure(projectId, error) { const now = Date.now(); if (!this.projectLimits.has(projectId)) { this.projectLimits.set(projectId, { requests: [], lastRequest: 0, consecutiveFailures: 0, backoffUntil: 0 }); } const limits = this.projectLimits.get(projectId); limits.consecutiveFailures++; // Only apply backoff for certain error types if (this.shouldBackoff(error)) { const backoffTime = Math.min( this.options.initialBackoff * Math.pow(this.options.backoffMultiplier, limits.consecutiveFailures - 1), this.options.maxBackoff ); limits.backoffUntil = now + backoffTime; if (this.options.debug) { console.error(`Rate limit: Backoff applied for ${projectId}, wait ${backoffTime}ms`); } } // Save state this.saveState(); } /** * Check throttle state for a project * @param {string} projectId - Project identifier * @returns {Object} { allowed: boolean, waitTime: number, reason: string } */ checkThrottleState(projectId) { const throttleState = this.throttleState.get(projectId); if (!throttleState) { return { allowed: true }; } const now = Date.now(); if (throttleState.retryAfter > now) { return { allowed: false, waitTime: throttleState.retryAfter - now, reason: 'throttled', retryAfter: throttleState.retryAfter, throttleCount: throttleState.consecutiveThrottles }; } // Clear expired throttle state this.throttleState.delete(projectId); return { allowed: true }; } /** * Parse Retry-After header * @param {string|number} retryAfter - Retry-After header value * @returns {number} Milliseconds to wait */ parseRetryAfter(retryAfter) { if (!retryAfter) return null; // If it's a number, treat as seconds if (typeof retryAfter === 'number') { return retryAfter * 1000; } // If it's a string, could be seconds or HTTP date if (typeof retryAfter === 'string') { const seconds = parseInt(retryAfter, 10); if (!isNaN(seconds)) { return seconds * 1000; } // Try parsing as date const date = new Date(retryAfter); if (!isNaN(date.getTime())) { return Math.max(0, date.getTime() - Date.now()); } } return null; } /** * Check if an error should trigger backoff * @param {Error} error - Error object * @returns {boolean} Whether to apply backoff */ shouldBackoff(error) { if (!error) return false; const message = (error.message || '').toLowerCase(); const code = error.code; // Network errors should trigger backoff if (code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ENOTFOUND') { return true; } // Service unavailable if (message.includes('503') || message.includes('service unavailable')) { return true; } // Bad gateway if (message.includes('502') || message.includes('bad gateway')) { return true; } // Gateway timeout if (message.includes('504') || message.includes('gateway timeout')) { return true; } // Too many requests (should be handled by recordRateLimit, but just in case) if (message.includes('429') || message.includes('too many requests')) { return true; } return false; } /** * Get current rate limit status for a project * @param {string} projectId - Project identifier * @returns {Object} Current status */ getStatus(projectId) { const now = Date.now(); const limits = this.projectLimits.get(projectId); const throttle = this.throttleState.get(projectId); if (!limits) { return { projectId, requestsLastMinute: 0, requestsLastHour: 0, isThrottled: false, consecutiveFailures: 0, backoffUntil: null }; } const oneMinuteAgo = now - (60 * 1000); const oneHourAgo = now - (60 * 60 * 1000); return { projectId, requestsLastMinute: limits.requests.filter(r => r.timestamp > oneMinuteAgo).length, requestsLastHour: limits.requests.filter(r => r.timestamp > oneHourAgo).length, maxRequestsPerMinute: this.options.maxRequestsPerMinute, maxRequestsPerHour: this.options.maxRequestsPerHour, isThrottled: throttle && throttle.retryAfter > now, throttleRetryAfter: throttle?.retryAfter, consecutiveFailures: limits.consecutiveFailures, backoffUntil: limits.backoffUntil > now ? limits.backoffUntil : null, lastRequest: limits.lastRequest }; } /** * Get suggested wait time for optimal request timing * @param {string} projectId - Project identifier * @returns {number} Suggested wait time in milliseconds */ getSuggestedWaitTime(projectId) { const status = this.getStatus(projectId); const now = Date.now(); // If throttled, wait for throttle to clear if (status.isThrottled) { return status.throttleRetryAfter - now; } // If in backoff, wait for backoff to clear if (status.backoffUntil) { return status.backoffUntil - now; } // If approaching limits, suggest spacing requests if (status.requestsLastMinute >= this.options.maxRequestsPerMinute * 0.8) { return 5000; // 5 seconds } if (status.requestsLastMinute >= this.options.maxRequestsPerMinute * 0.6) { return 2000; // 2 seconds } return 0; // No wait needed } /** * Clean up old data */ cleanup() { const now = Date.now(); const oneHourAgo = now - (60 * 60 * 1000); // Clean up project limits for (const [projectId, limits] of this.projectLimits.entries()) { // Remove old requests limits.requests = limits.requests.filter(r => r.timestamp > oneHourAgo); // Remove project if no recent activity if (limits.requests.length === 0 && limits.lastRequest < oneHourAgo) { this.projectLimits.delete(projectId); } } // Clean up throttle states for (const [projectId, throttle] of this.throttleState.entries()) { if (throttle.retryAfter < now) { this.throttleState.delete(projectId); } } // Save cleaned state this.saveState(); if (this.options.debug && (this.projectLimits.size > 0 || this.throttleState.size > 0)) { console.error(`Rate limiter: Cleaned up, ${this.projectLimits.size} projects, ${this.throttleState.size} throttled`); } } /** * Reset rate limits for a project (for testing) * @param {string} projectId - Project identifier */ reset(projectId) { if (projectId) { this.projectLimits.delete(projectId); this.throttleState.delete(projectId); } else { this.projectLimits.clear(); this.throttleState.clear(); } this.saveState(); } /** * Destroy rate limiter and clean up */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.saveState(); } } module.exports = RateLimiter;

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/JaxonDigital/optimizely-dxp-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server