/** * Rate limiting using Redis sliding window algorithm. * * Tiers: * - free: 100 requests/minute * - developer: 1000 requests/minute * - enterprise: unlimited */ // Use require for ioredis to avoid ESM import issues import type { Redis } from 'ioredis'; import IORedisModule from 'ioredis'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const IORedis = (IORedisModule as any).default || IORedisModule; export type RateLimitTier = 'free' | 'developer' | 'enterprise'; interface TierConfig { requestsPerMinute: number; burstLimit: number; } const TIER_CONFIGS: Record = { free: { requestsPerMinute: 100, burstLimit: 20 }, developer: { requestsPerMinute: 1000, burstLimit: 100 }, enterprise: { requestsPerMinute: Infinity, burstLimit: Infinity }, }; export interface RateLimitResult { allowed: boolean; remaining: number; resetAt: number; tier: RateLimitTier; } export class RateLimiter { private redis: Redis; private windowMs = 60_000; // 1 minute constructor(redisUrl: string) { this.redis = new IORedis(redisUrl); } async checkLimit(key: string, tier: RateLimitTier): Promise { const config = TIER_CONFIGS[tier]; // Enterprise tier bypasses rate limiting if (tier === 'enterprise') { return { allowed: true, remaining: Infinity, resetAt: 0, tier, }; } const now = Date.now(); const windowStart = now - this.windowMs; const redisKey = `ratelimit:${key}`; // Use Redis sorted set for sliding window const pipeline = this.redis.pipeline(); // Remove expired entries pipeline.zremrangebyscore(redisKey, 0, windowStart); // Count current requests in window pipeline.zcard(redisKey); // Add current request pipeline.zadd(redisKey, now, `${now}-${Math.random()}`); // Set expiry on the key pipeline.expire(redisKey, 120); const results = await pipeline.exec(); const currentCount = (results?.[1]?.[1] as number) || 0; const allowed = currentCount < config.requestsPerMinute; const remaining = Math.max(0, config.requestsPerMinute - currentCount - 1); // Calculate reset time const oldestRequest = await this.redis.zrange(redisKey, 0, 0, 'WITHSCORES'); const resetAt = oldestRequest.length >= 2 ? parseInt(oldestRequest[1]) + this.windowMs : now + this.windowMs; return { allowed, remaining: allowed ? remaining : 0, resetAt, tier, }; } async getUsageStats(key: string): Promise<{ requestsLastMinute: number; requestsLastHour: number; requestsLastDay: number; }> { const now = Date.now(); const redisKey = `ratelimit:${key}`; const pipeline = this.redis.pipeline(); pipeline.zcount(redisKey, now - 60_000, now); pipeline.zcount(redisKey, now - 3600_000, now); pipeline.zcount(redisKey, now - 86400_000, now); const results = await pipeline.exec(); return { requestsLastMinute: (results?.[0]?.[1] as number) || 0, requestsLastHour: (results?.[1]?.[1] as number) || 0, requestsLastDay: (results?.[2]?.[1] as number) || 0, }; } async close(): Promise { await this.redis.quit(); } }