/** * Token bucket rate limiter for Google Search Console API quotas * Implements per-API-type tracking with automatic token refill */ export type APIType = 'gsc' | 'inspection' | 'indexing'; interface BucketConfig { capacity: number; refillRate: number; // tokens per millisecond refillInterval: number; // interval in ms } interface TokenBucket { tokens: number; lastRefill: number; config: BucketConfig; queue: Array<{ resolve: () => void; tokens: number }>; } export interface RateLimitConfig { /** GSC API: 1200 queries/minute (default) */ gscQueries?: number; /** URL Inspection: 600/day per property, 2000/day total */ inspectionQueries?: number; /** Indexing API: 200/day */ indexingQueries?: number; } export class RateLimiter { private buckets = new Map(); constructor(config: RateLimitConfig = {}) { // GSC API: 1200 queries/minute const gscQueriesPerMin = config.gscQueries ?? 1200; this.buckets.set('gsc', { tokens: gscQueriesPerMin, lastRefill: Date.now(), config: { capacity: gscQueriesPerMin, refillRate: gscQueriesPerMin / (60 * 1000), // per ms refillInterval: 60 * 1000 // 1 minute }, queue: [] }); // URL Inspection: 600/day (simplified - not tracking per-property) const inspectionQueriesPerDay = config.inspectionQueries ?? 600; this.buckets.set('inspection', { tokens: inspectionQueriesPerDay, lastRefill: Date.now(), config: { capacity: inspectionQueriesPerDay, refillRate: inspectionQueriesPerDay / (24 * 60 * 60 * 1000), // per ms refillInterval: 24 * 60 * 60 * 1000 // 1 day }, queue: [] }); // Indexing API: 200/day const indexingQueriesPerDay = config.indexingQueries ?? 200; this.buckets.set('indexing', { tokens: indexingQueriesPerDay, lastRefill: Date.now(), config: { capacity: indexingQueriesPerDay, refillRate: indexingQueriesPerDay / (24 * 60 * 60 * 1000), // per ms refillInterval: 24 * 60 * 60 * 1000 // 1 day }, queue: [] }); } /** * Refill tokens in a bucket based on elapsed time */ private refillBucket(bucket: TokenBucket): void { const now = Date.now(); const elapsed = now - bucket.lastRefill; const tokensToAdd = elapsed * bucket.config.refillRate; bucket.tokens = Math.min( bucket.config.capacity, bucket.tokens + tokensToAdd ); bucket.lastRefill = now; } /** * Process queued requests if tokens available */ private processQueue(apiType: APIType): void { const bucket = this.buckets.get(apiType); if (!bucket) return; this.refillBucket(bucket); while (bucket.queue.length > 0 && bucket.tokens >= bucket.queue[0].tokens) { const item = bucket.queue.shift()!; bucket.tokens -= item.tokens; item.resolve(); } } /** * Acquire tokens for an API call * Waits if insufficient tokens are available */ async acquire(apiType: APIType, tokens: number = 1): Promise { const bucket = this.buckets.get(apiType); if (!bucket) { throw new Error(`Unknown API type: ${apiType}`); } this.refillBucket(bucket); // If enough tokens available, consume immediately if (bucket.tokens >= tokens) { bucket.tokens -= tokens; return; } // Otherwise, queue the request return new Promise((resolve) => { bucket.queue.push({ resolve, tokens }); // Set up periodic processing const interval = setInterval(() => { this.processQueue(apiType); // Stop if this request was processed if (!bucket.queue.find(item => item.resolve === resolve)) { clearInterval(interval); } }, 100); // Check every 100ms }); } /** * Get current token counts for monitoring */ getStatus(): Record { const status = {} as Record; for (const [apiType, bucket] of this.buckets.entries()) { this.refillBucket(bucket); status[apiType] = { tokens: Math.floor(bucket.tokens), capacity: bucket.config.capacity, queued: bucket.queue.length }; } return status; } /** * Reset all buckets (useful for testing) */ reset(): void { for (const bucket of this.buckets.values()) { bucket.tokens = bucket.config.capacity; bucket.lastRefill = Date.now(); bucket.queue = []; } } }