281 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			281 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import { logger } from '../../logger.ts'; | ||
|  | 
 | ||
|  | /** | ||
|  |  * Configuration options for rate limiter | ||
|  |  */ | ||
|  | export interface IRateLimitConfig { | ||
|  |   /** Maximum tokens per period */ | ||
|  |   maxPerPeriod: number; | ||
|  |    | ||
|  |   /** Time period in milliseconds */ | ||
|  |   periodMs: number; | ||
|  |    | ||
|  |   /** Whether to apply per domain/key (vs globally) */ | ||
|  |   perKey: boolean; | ||
|  |    | ||
|  |   /** Initial token count (defaults to max) */ | ||
|  |   initialTokens?: number; | ||
|  |    | ||
|  |   /** Grace tokens to allow occasional bursts */ | ||
|  |   burstTokens?: number; | ||
|  |    | ||
|  |   /** Apply global limit in addition to per-key limits */ | ||
|  |   useGlobalLimit?: boolean; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Token bucket for an individual key | ||
|  |  */ | ||
|  | interface TokenBucket { | ||
|  |   /** Current number of tokens */ | ||
|  |   tokens: number; | ||
|  |    | ||
|  |   /** Last time tokens were refilled */ | ||
|  |   lastRefill: number; | ||
|  |    | ||
|  |   /** Total allowed requests */ | ||
|  |   allowed: number; | ||
|  |    | ||
|  |   /** Total denied requests */ | ||
|  |   denied: number; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Rate limiter using token bucket algorithm | ||
|  |  * Provides more sophisticated rate limiting with burst handling | ||
|  |  */ | ||
|  | export class RateLimiter { | ||
|  |   /** Rate limit configuration */ | ||
|  |   private config: IRateLimitConfig; | ||
|  |    | ||
|  |   /** Token buckets per key */ | ||
|  |   private buckets: Map<string, TokenBucket> = new Map(); | ||
|  |    | ||
|  |   /** Global bucket for non-keyed rate limiting */ | ||
|  |   private globalBucket: TokenBucket; | ||
|  |    | ||
|  |   /** | ||
|  |    * Create a new rate limiter | ||
|  |    * @param config Rate limiter configuration | ||
|  |    */ | ||
|  |   constructor(config: IRateLimitConfig) { | ||
|  |     // Set defaults
 | ||
|  |     this.config = { | ||
|  |       maxPerPeriod: config.maxPerPeriod, | ||
|  |       periodMs: config.periodMs, | ||
|  |       perKey: config.perKey ?? true, | ||
|  |       initialTokens: config.initialTokens ?? config.maxPerPeriod, | ||
|  |       burstTokens: config.burstTokens ?? 0, | ||
|  |       useGlobalLimit: config.useGlobalLimit ?? false | ||
|  |     }; | ||
|  |      | ||
|  |     // Initialize global bucket
 | ||
|  |     this.globalBucket = { | ||
|  |       tokens: this.config.initialTokens, | ||
|  |       lastRefill: Date.now(), | ||
|  |       allowed: 0, | ||
|  |       denied: 0 | ||
|  |     }; | ||
|  |      | ||
|  |     // Log initialization
 | ||
|  |     logger.log('info', `Rate limiter initialized: ${this.config.maxPerPeriod} per ${this.config.periodMs}ms${this.config.perKey ? ' per key' : ''}`); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Check if a request is allowed under rate limits | ||
|  |    * @param key Key to check rate limit for (e.g. domain, user, IP) | ||
|  |    * @param cost Token cost (defaults to 1) | ||
|  |    * @returns Whether the request is allowed | ||
|  |    */ | ||
|  |   public isAllowed(key: string = 'global', cost: number = 1): boolean { | ||
|  |     // If using global bucket directly, just check that
 | ||
|  |     if (key === 'global' || !this.config.perKey) { | ||
|  |       return this.checkBucket(this.globalBucket, cost); | ||
|  |     } | ||
|  |      | ||
|  |     // Get the key-specific bucket
 | ||
|  |     const bucket = this.getBucket(key); | ||
|  |      | ||
|  |     // If we also need to check global limit
 | ||
|  |     if (this.config.useGlobalLimit) { | ||
|  |       // Both key bucket and global bucket must have tokens
 | ||
|  |       return this.checkBucket(bucket, cost) && this.checkBucket(this.globalBucket, cost); | ||
|  |     } else { | ||
|  |       // Only need to check the key-specific bucket
 | ||
|  |       return this.checkBucket(bucket, cost); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Check if a bucket has enough tokens and consume them | ||
|  |    * @param bucket The token bucket to check | ||
|  |    * @param cost Token cost | ||
|  |    * @returns Whether tokens were consumed | ||
|  |    */ | ||
|  |   private checkBucket(bucket: TokenBucket, cost: number): boolean { | ||
|  |     // Refill tokens based on elapsed time
 | ||
|  |     this.refillBucket(bucket); | ||
|  |      | ||
|  |     // Check if we have enough tokens
 | ||
|  |     if (bucket.tokens >= cost) { | ||
|  |       // Use tokens
 | ||
|  |       bucket.tokens -= cost; | ||
|  |       bucket.allowed++; | ||
|  |       return true; | ||
|  |     } else { | ||
|  |       // Rate limit exceeded
 | ||
|  |       bucket.denied++; | ||
|  |       return false; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Consume tokens for a request (if available) | ||
|  |    * @param key Key to consume tokens for | ||
|  |    * @param cost Token cost (defaults to 1) | ||
|  |    * @returns Whether tokens were consumed | ||
|  |    */ | ||
|  |   public consume(key: string = 'global', cost: number = 1): boolean { | ||
|  |     const isAllowed = this.isAllowed(key, cost); | ||
|  |     return isAllowed; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Get the remaining tokens for a key | ||
|  |    * @param key Key to check | ||
|  |    * @returns Number of remaining tokens | ||
|  |    */ | ||
|  |   public getRemainingTokens(key: string = 'global'): number { | ||
|  |     const bucket = this.getBucket(key); | ||
|  |     this.refillBucket(bucket); | ||
|  |     return bucket.tokens; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Get stats for a specific key | ||
|  |    * @param key Key to get stats for | ||
|  |    * @returns Rate limit statistics | ||
|  |    */ | ||
|  |   public getStats(key: string = 'global'): { | ||
|  |     remaining: number; | ||
|  |     limit: number; | ||
|  |     resetIn: number; | ||
|  |     allowed: number; | ||
|  |     denied: number; | ||
|  |   } { | ||
|  |     const bucket = this.getBucket(key); | ||
|  |     this.refillBucket(bucket); | ||
|  |      | ||
|  |     // Calculate time until next token
 | ||
|  |     const resetIn = bucket.tokens < this.config.maxPerPeriod ? | ||
|  |       Math.ceil(this.config.periodMs / this.config.maxPerPeriod) : | ||
|  |       0; | ||
|  |      | ||
|  |     return { | ||
|  |       remaining: bucket.tokens, | ||
|  |       limit: this.config.maxPerPeriod, | ||
|  |       resetIn, | ||
|  |       allowed: bucket.allowed, | ||
|  |       denied: bucket.denied | ||
|  |     }; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Get or create a token bucket for a key | ||
|  |    * @param key The rate limit key | ||
|  |    * @returns Token bucket | ||
|  |    */ | ||
|  |   private getBucket(key: string): TokenBucket { | ||
|  |     if (!this.config.perKey || key === 'global') { | ||
|  |       return this.globalBucket; | ||
|  |     } | ||
|  |      | ||
|  |     if (!this.buckets.has(key)) { | ||
|  |       // Create new bucket
 | ||
|  |       this.buckets.set(key, { | ||
|  |         tokens: this.config.initialTokens, | ||
|  |         lastRefill: Date.now(), | ||
|  |         allowed: 0, | ||
|  |         denied: 0 | ||
|  |       }); | ||
|  |     } | ||
|  |      | ||
|  |     return this.buckets.get(key); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Refill tokens in a bucket based on elapsed time | ||
|  |    * @param bucket Token bucket to refill | ||
|  |    */ | ||
|  |   private refillBucket(bucket: TokenBucket): void { | ||
|  |     const now = Date.now(); | ||
|  |     const elapsedMs = now - bucket.lastRefill; | ||
|  |      | ||
|  |     // Calculate how many tokens to add
 | ||
|  |     const rate = this.config.maxPerPeriod / this.config.periodMs; | ||
|  |     const tokensToAdd = elapsedMs * rate; | ||
|  |      | ||
|  |     if (tokensToAdd >= 0.1) { // Allow for partial token refills
 | ||
|  |       // Add tokens, but don't exceed the normal maximum (without burst)
 | ||
|  |       // This ensures burst tokens are only used for bursts and don't refill
 | ||
|  |       const normalMax = this.config.maxPerPeriod; | ||
|  |       bucket.tokens = Math.min( | ||
|  |         // Don't exceed max + burst
 | ||
|  |         this.config.maxPerPeriod + (this.config.burstTokens || 0),  | ||
|  |         // Don't exceed normal max when refilling
 | ||
|  |         Math.min(normalMax, bucket.tokens + tokensToAdd) | ||
|  |       ); | ||
|  |        | ||
|  |       // Update last refill time
 | ||
|  |       bucket.lastRefill = now; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Reset rate limits for a specific key | ||
|  |    * @param key Key to reset | ||
|  |    */ | ||
|  |   public reset(key: string = 'global'): void { | ||
|  |     if (key === 'global' || !this.config.perKey) { | ||
|  |       this.globalBucket.tokens = this.config.initialTokens; | ||
|  |       this.globalBucket.lastRefill = Date.now(); | ||
|  |     } else if (this.buckets.has(key)) { | ||
|  |       const bucket = this.buckets.get(key); | ||
|  |       bucket.tokens = this.config.initialTokens; | ||
|  |       bucket.lastRefill = Date.now(); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Reset all rate limiters | ||
|  |    */ | ||
|  |   public resetAll(): void { | ||
|  |     this.globalBucket.tokens = this.config.initialTokens; | ||
|  |     this.globalBucket.lastRefill = Date.now(); | ||
|  |      | ||
|  |     for (const bucket of this.buckets.values()) { | ||
|  |       bucket.tokens = this.config.initialTokens; | ||
|  |       bucket.lastRefill = Date.now(); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Cleanup old buckets to prevent memory leaks | ||
|  |    * @param maxAge Maximum age in milliseconds | ||
|  |    */ | ||
|  |   public cleanup(maxAge: number = 24 * 60 * 60 * 1000): void { | ||
|  |     const now = Date.now(); | ||
|  |     let removed = 0; | ||
|  |      | ||
|  |     for (const [key, bucket] of this.buckets.entries()) { | ||
|  |       if (now - bucket.lastRefill > maxAge) { | ||
|  |         this.buckets.delete(key); | ||
|  |         removed++; | ||
|  |       } | ||
|  |     } | ||
|  |      | ||
|  |     if (removed > 0) { | ||
|  |       logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`); | ||
|  |     } | ||
|  |   } | ||
|  | } |