UNPKG

@spaik/mcp-server-roi

Version:

MCP server for AI ROI prediction and tracking with Monte Carlo simulations

197 lines 6.86 kB
import { createLogger } from './logger.js'; import { TimeoutError } from './errors.js'; /** * Token bucket rate limiter with sliding window */ export class RateLimiter { logger = createLogger({ component: 'RateLimiter' }); requestTimestamps = []; currentConcurrent = 0; config; constructor(config) { this.config = { maxRequestsPerWindow: config.maxRequestsPerWindow, windowMs: config.windowMs, maxConcurrent: config.maxConcurrent || Math.ceil(config.maxRequestsPerWindow / 2), retryAfterMs: config.retryAfterMs || 1000, name: config.name || 'default' }; this.logger.info('Rate limiter initialized', { name: this.config.name, maxRequests: this.config.maxRequestsPerWindow, windowMs: this.config.windowMs, maxConcurrent: this.config.maxConcurrent }); } /** * Check if a request can be made without hitting rate limits */ canMakeRequest() { this.cleanupOldTimestamps(); const now = Date.now(); const requestsInWindow = this.requestTimestamps.length; const remainingRequests = this.config.maxRequestsPerWindow - requestsInWindow; // Calculate when the next request slot will be available let resetTime = new Date(now + this.config.windowMs); if (this.requestTimestamps.length > 0) { const oldestTimestamp = this.requestTimestamps[0]; resetTime = new Date(oldestTimestamp + this.config.windowMs); } const isLimited = requestsInWindow >= this.config.maxRequestsPerWindow || this.currentConcurrent >= this.config.maxConcurrent; return { remainingRequests: Math.max(0, remainingRequests), resetTime, isLimited, retryAfter: isLimited ? this.calculateRetryAfter() : undefined }; } /** * Wait for rate limit if necessary and execute request */ async executeWithRateLimit(fn, options) { const priority = options?.priority || 'normal'; const timeout = options?.timeout || 30000; // Wait for rate limit clearance await this.waitForSlot(priority, timeout); // Track concurrent request this.currentConcurrent++; try { // Execute the function const result = await fn(); // Record successful request this.recordRequest(); return result; } finally { // Always decrement concurrent count this.currentConcurrent--; } } /** * Wait for an available request slot */ async waitForSlot(priority, timeout) { const startTime = Date.now(); while (true) { const limitInfo = this.canMakeRequest(); if (!limitInfo.isLimited) { return; // Slot available } // Check timeout if (Date.now() - startTime > timeout) { throw new TimeoutError('Rate limit wait timeout', timeout); } // Calculate wait time based on priority let waitTime = limitInfo.retryAfter || this.config.retryAfterMs; if (priority === 'low') { waitTime *= 1.5; } else if (priority === 'high') { waitTime *= 0.7; } this.logger.debug('Waiting for rate limit slot', { name: this.config.name, waitTime, priority, remainingRequests: limitInfo.remainingRequests, currentConcurrent: this.currentConcurrent }); // Wait before retrying await new Promise(resolve => setTimeout(resolve, waitTime)); } } /** * Record a request timestamp */ recordRequest() { const now = Date.now(); this.requestTimestamps.push(now); this.logger.debug('Request recorded', { name: this.config.name, totalRequests: this.requestTimestamps.length, concurrent: this.currentConcurrent }); } /** * Remove timestamps outside the current window */ cleanupOldTimestamps() { const now = Date.now(); const windowStart = now - this.config.windowMs; // Remove timestamps older than the window this.requestTimestamps = this.requestTimestamps.filter(timestamp => timestamp > windowStart); } /** * Calculate retry after time based on current state */ calculateRetryAfter() { if (this.requestTimestamps.length === 0) { return this.config.retryAfterMs; } // Find when the oldest request will expire const oldestTimestamp = this.requestTimestamps[0]; const expireTime = oldestTimestamp + this.config.windowMs; const now = Date.now(); return Math.max(this.config.retryAfterMs, expireTime - now); } /** * Get current rate limiter statistics */ getStats() { this.cleanupOldTimestamps(); const limitInfo = this.canMakeRequest(); return { name: this.config.name, requestsInWindow: this.requestTimestamps.length, currentConcurrent: this.currentConcurrent, remainingRequests: limitInfo.remainingRequests, isLimited: limitInfo.isLimited }; } /** * Reset the rate limiter */ reset() { this.requestTimestamps = []; this.currentConcurrent = 0; this.logger.info('Rate limiter reset', { name: this.config.name }); } } /** * Create rate limiters for different services */ export const rateLimiters = { perplexity: new RateLimiter({ name: 'perplexity', maxRequestsPerWindow: 50, // 50 requests per minute windowMs: 60 * 1000, // 1 minute maxConcurrent: 5 }), fmp: new RateLimiter({ name: 'fmp', maxRequestsPerWindow: 250, // 250 requests per minute (free tier) windowMs: 60 * 1000, maxConcurrent: 10 }), supabase: new RateLimiter({ name: 'supabase', maxRequestsPerWindow: 500, // 500 requests per minute windowMs: 60 * 1000, maxConcurrent: 20 }) }; /** * Decorator for rate-limited methods */ export function RateLimited(limiterName) { return function (target, propertyKey, descriptor) { const originalMethod = descriptor.value; descriptor.value = async function (...args) { const limiter = rateLimiters[limiterName]; return limiter.executeWithRateLimit(() => originalMethod.apply(this, args), { priority: 'normal' }); }; return descriptor; }; } //# sourceMappingURL=rate-limiter.js.map