import { createLogger } from './logger.js';
import { TimeoutError } from './errors.js';
export interface RateLimiterConfig {
maxRequestsPerWindow: number;
windowMs: number;
maxConcurrent?: number;
retryAfterMs?: number;
name?: string;
}
export interface RateLimitInfo {
remainingRequests: number;
resetTime: Date;
isLimited: boolean;
retryAfter?: number;
}
/**
* Token bucket rate limiter with sliding window
*/
export class RateLimiter {
private logger = createLogger({ component: 'RateLimiter' });
private requestTimestamps: number[] = [];
private currentConcurrent = 0;
private readonly config: Required<RateLimiterConfig>;
constructor(config: RateLimiterConfig) {
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(): RateLimitInfo {
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<T>(
fn: () => Promise<T>,
options?: {
priority?: 'high' | 'normal' | 'low';
timeout?: number;
}
): Promise<T> {
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
*/
private async waitForSlot(priority: string, timeout: number): Promise<void> {
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
*/
private recordRequest(): void {
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
*/
private cleanupOldTimestamps(): void {
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
*/
private calculateRetryAfter(): number {
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(): {
name: string;
requestsInWindow: number;
currentConcurrent: number;
remainingRequests: number;
isLimited: boolean;
} {
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(): void {
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: keyof typeof rateLimiters) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const limiter = rateLimiters[limiterName];
return limiter.executeWithRateLimit(
() => originalMethod.apply(this, args),
{ priority: 'normal' }
);
};
return descriptor;
};
}