perplexity-mcp-server
Version:
A Perplexity API Model Context Protocol (MCP) server that unlocks Perplexity's search-augmented AI capabilities for LLM agents. Features robust error handling, secure input validation, and transparent reasoning with the showThinking parameter. Built with
180 lines (179 loc) • 6.24 kB
JavaScript
/**
* @fileoverview Provides a generic `RateLimiter` class for implementing rate limiting logic.
* It supports configurable time windows, request limits, and automatic cleanup of expired entries.
* @module src/utils/security/rateLimiter
*/
import { environment } from "../../config/index.js";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { logger, requestContextService } from "../index.js";
/**
* A generic rate limiter class using an in-memory store.
* Controls frequency of operations based on unique keys.
*/
export class RateLimiter {
/**
* Creates a new `RateLimiter` instance.
* @param config - Configuration options, merged with defaults.
*/
constructor(config) {
this.config = config;
/**
* Timer ID for periodic cleanup.
* @private
*/
this.cleanupTimer = null;
this.config = { ...RateLimiter.DEFAULT_CONFIG, ...config };
this.limits = new Map();
this.startCleanupTimer();
}
/**
* Starts the periodic timer to clean up expired rate limit entries.
* @private
*/
startCleanupTimer() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
const interval = this.config.cleanupInterval ?? RateLimiter.DEFAULT_CONFIG.cleanupInterval;
if (interval && interval > 0) {
this.cleanupTimer = setInterval(() => {
this.cleanupExpiredEntries();
}, interval);
if (this.cleanupTimer.unref) {
this.cleanupTimer.unref(); // Allow Node.js process to exit if only timer active
}
}
}
/**
* Removes expired rate limit entries from the store.
* @private
*/
cleanupExpiredEntries() {
const now = Date.now();
let expiredCount = 0;
for (const [key, entry] of this.limits.entries()) {
if (now >= entry.resetTime) {
this.limits.delete(key);
expiredCount++;
}
}
if (expiredCount > 0) {
const logContext = requestContextService.createRequestContext({
operation: "RateLimiter.cleanupExpiredEntries",
cleanedCount: expiredCount,
totalRemainingAfterClean: this.limits.size,
});
logger.debug(`Cleaned up ${expiredCount} expired rate limit entries`, logContext);
}
}
/**
* Updates the configuration of the rate limiter instance.
* @param config - New configuration options to merge.
*/
configure(config) {
this.config = { ...this.config, ...config };
if (config.cleanupInterval !== undefined) {
this.startCleanupTimer();
}
}
/**
* Retrieves a copy of the current rate limiter configuration.
* @returns The current configuration.
*/
getConfig() {
return { ...this.config };
}
/**
* Resets all rate limits by clearing the internal store.
*/
reset() {
this.limits.clear();
const logContext = requestContextService.createRequestContext({
operation: "RateLimiter.reset",
});
logger.debug("Rate limiter reset, all limits cleared", logContext);
}
/**
* Checks if a request exceeds the configured rate limit.
* Throws an `McpError` if the limit is exceeded.
*
* @param key - A unique identifier for the request source.
* @param context - Optional request context for custom key generation.
* @throws {McpError} If the rate limit is exceeded.
*/
check(key, context) {
if (this.config.skipInDevelopment && environment === "development") {
return;
}
const limitKey = this.config.keyGenerator
? this.config.keyGenerator(key, context)
: key;
const now = Date.now();
const entry = this.limits.get(limitKey);
if (!entry || now >= entry.resetTime) {
this.limits.set(limitKey, {
count: 1,
resetTime: now + this.config.windowMs,
});
return;
}
if (entry.count >= this.config.maxRequests) {
const waitTime = Math.ceil((entry.resetTime - now) / 1000);
const errorMessage = (this.config.errorMessage || RateLimiter.DEFAULT_CONFIG.errorMessage).replace("{waitTime}", waitTime.toString());
throw new McpError(BaseErrorCode.RATE_LIMITED, errorMessage, {
waitTimeSeconds: waitTime,
key: limitKey,
limit: this.config.maxRequests,
windowMs: this.config.windowMs,
});
}
entry.count++;
}
/**
* Retrieves the current rate limit status for a specific key.
* @param key - The rate limit key.
* @returns Status object or `null` if no entry exists.
*/
getStatus(key) {
const entry = this.limits.get(key);
if (!entry) {
return null;
}
return {
current: entry.count,
limit: this.config.maxRequests,
remaining: Math.max(0, this.config.maxRequests - entry.count),
resetTime: entry.resetTime,
};
}
/**
* Stops the cleanup timer and clears all rate limit entries.
* Call when the rate limiter is no longer needed.
*/
dispose() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
this.limits.clear();
}
}
/**
* Default configuration values.
* @private
*/
RateLimiter.DEFAULT_CONFIG = {
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 100,
errorMessage: "Rate limit exceeded. Please try again in {waitTime} seconds.",
skipInDevelopment: false,
cleanupInterval: 5 * 60 * 1000, // 5 minutes
};
/**
* Default singleton instance of the `RateLimiter`.
* Initialized with default configuration. Use `rateLimiter.configure({})` to customize.
*/
export const rateLimiter = new RateLimiter({
windowMs: 15 * 60 * 1000, // Default: 15 minutes
maxRequests: 100, // Default: 100 requests per window
});