/** * Rate Limiter * * Production-ready rate limiting implementations. * * @module v3/shared/resilience/rate-limiter */ /** * Sliding Window Rate Limiter * * Uses sliding window algorithm for smooth rate limiting. * * @example * const limiter = new SlidingWindowRateLimiter({ * maxRequests: 100, * windowMs: 60000, // 100 requests per minute * }); * * const result = limiter.consume('user-123'); * if (!result.allowed) { * throw new Error(`Rate limited. Retry in ${result.retryAfter}ms`); * } */ export class SlidingWindowRateLimiter { options; requests = new Map(); cleanupInterval; constructor(options) { this.options = { slidingWindow: true, ...options, }; // Periodic cleanup of old entries this.cleanupInterval = setInterval(() => { this.cleanup(); }, this.options.windowMs); } /** * Check if a request would be allowed without consuming */ check(key = 'default') { this.cleanupKey(key); const entries = this.requests.get(key) || []; return { allowed: entries.length < this.options.maxRequests, remaining: Math.max(0, this.options.maxRequests - entries.length), resetAt: this.getResetTime(entries), retryAfter: this.getRetryAfter(entries), total: this.options.maxRequests, used: entries.length, }; } /** * Consume a request token */ consume(key = 'default') { // Clean old entries first this.cleanupKey(key); let entries = this.requests.get(key); if (!entries) { entries = []; this.requests.set(key, entries); } // Check if allowed if (entries.length >= this.options.maxRequests) { const result = { allowed: false, remaining: 0, resetAt: this.getResetTime(entries), retryAfter: this.getRetryAfter(entries), total: this.options.maxRequests, used: entries.length, }; this.options.onRateLimited?.(key, 0, result.resetAt); return result; } // Add new entry entries.push({ timestamp: Date.now(), key }); return { allowed: true, remaining: this.options.maxRequests - entries.length, resetAt: this.getResetTime(entries), retryAfter: 0, total: this.options.maxRequests, used: entries.length, }; } /** * Reset rate limit for a key */ reset(key) { if (key) { this.requests.delete(key); } else { this.requests.clear(); } } /** * Get current status */ status(key = 'default') { return this.check(key); } /** * Cleanup resources */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } this.requests.clear(); } /** * Clean old entries for a specific key */ cleanupKey(key) { const entries = this.requests.get(key); if (!entries) return; const cutoff = Date.now() - this.options.windowMs; const filtered = entries.filter((e) => e.timestamp >= cutoff); if (filtered.length === 0) { this.requests.delete(key); } else if (filtered.length !== entries.length) { this.requests.set(key, filtered); } } /** * Clean all old entries */ cleanup() { for (const key of this.requests.keys()) { this.cleanupKey(key); } } /** * Get reset time based on oldest entry */ getResetTime(entries) { if (entries.length === 0) { return new Date(Date.now() + this.options.windowMs); } const oldest = entries[0]; return new Date(oldest.timestamp + this.options.windowMs); } /** * Get retry after time in ms */ getRetryAfter(entries) { if (entries.length < this.options.maxRequests) { return 0; } const oldest = entries[0]; const resetAt = oldest.timestamp + this.options.windowMs; return Math.max(0, resetAt - Date.now()); } } /** * Token Bucket Rate Limiter * * Uses token bucket algorithm for burst-friendly rate limiting. * * @example * const limiter = new TokenBucketRateLimiter({ * maxRequests: 10, // bucket size * windowMs: 1000, // refill interval * }); */ export class TokenBucketRateLimiter { options; buckets = new Map(); cleanupInterval; constructor(options) { this.options = options; // Periodic cleanup this.cleanupInterval = setInterval(() => { this.cleanup(); }, this.options.windowMs * 10); } /** * Check if a request would be allowed */ check(key = 'default') { this.refill(key); const bucket = this.getBucket(key); return { allowed: bucket.tokens >= 1, remaining: Math.floor(bucket.tokens), resetAt: new Date(bucket.lastRefill + this.options.windowMs), retryAfter: bucket.tokens >= 1 ? 0 : this.options.windowMs, total: this.options.maxRequests, used: this.options.maxRequests - Math.floor(bucket.tokens), }; } /** * Consume a token */ consume(key = 'default') { this.refill(key); const bucket = this.getBucket(key); if (bucket.tokens < 1) { const result = { allowed: false, remaining: 0, resetAt: new Date(bucket.lastRefill + this.options.windowMs), retryAfter: this.options.windowMs, total: this.options.maxRequests, used: this.options.maxRequests, }; this.options.onRateLimited?.(key, 0, result.resetAt); return result; } bucket.tokens -= 1; return { allowed: true, remaining: Math.floor(bucket.tokens), resetAt: new Date(bucket.lastRefill + this.options.windowMs), retryAfter: 0, total: this.options.maxRequests, used: this.options.maxRequests - Math.floor(bucket.tokens), }; } /** * Reset bucket for a key */ reset(key) { if (key) { this.buckets.delete(key); } else { this.buckets.clear(); } } /** * Get current status */ status(key = 'default') { return this.check(key); } /** * Cleanup resources */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } this.buckets.clear(); } /** * Get or create bucket for key */ getBucket(key) { let bucket = this.buckets.get(key); if (!bucket) { bucket = { tokens: this.options.maxRequests, lastRefill: Date.now() }; this.buckets.set(key, bucket); } return bucket; } /** * Refill tokens based on elapsed time */ refill(key) { const bucket = this.getBucket(key); const now = Date.now(); const elapsed = now - bucket.lastRefill; if (elapsed >= this.options.windowMs) { // Full refill after window const intervals = Math.floor(elapsed / this.options.windowMs); bucket.tokens = Math.min(this.options.maxRequests, bucket.tokens + intervals * this.options.maxRequests); bucket.lastRefill = now; } } /** * Clean inactive buckets */ cleanup() { const cutoff = Date.now() - this.options.windowMs * 10; for (const [key, bucket] of this.buckets) { if (bucket.lastRefill < cutoff && bucket.tokens >= this.options.maxRequests) { this.buckets.delete(key); } } } } /** * Rate limiter middleware for Express-like frameworks */ export function createRateLimiterMiddleware(limiter) { return (req, res, next) => { // Get key from IP or header const key = req.ip || req.headers?.['x-forwarded-for'] || 'anonymous'; const result = limiter.consume(key); // Set rate limit headers res.setHeader('X-RateLimit-Limit', String(result.total)); res.setHeader('X-RateLimit-Remaining', String(result.remaining)); res.setHeader('X-RateLimit-Reset', String(Math.ceil(result.resetAt.getTime() / 1000))); if (!result.allowed) { res.setHeader('Retry-After', String(Math.ceil(result.retryAfter / 1000))); res.status(429).json({ error: 'Too Many Requests', retryAfter: result.retryAfter, resetAt: result.resetAt.toISOString(), }); return; } next(); }; } //# sourceMappingURL=rate-limiter.js.map