import type { RateLimitBucket, RateLimitOptions } from "./types"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export class RateLimitManager { private buckets = new Map(); private routeToBucketHash = new Map(); private globalResetTime = 0; private options: RateLimitOptions; private baseUrl: string | undefined; constructor(options: RateLimitOptions = {}) { this.options = options; } private debug(message: string, data?: Record) { if (this.options.onDebug) this.options.onDebug(message, data); this.baseUrl = this.options.baseUrl ?? "https://discord.com"; } private getRouteId(method: string, url: string): string { try { const parsed = new URL(url, this.baseUrl); const path = parsed.pathname.replace(/^\/api\/v\d+/, ""); const route = path.replace(/\b\d{17,20}\b/g, (match, offset, fullStr) => { const prefix = fullStr.substring(0, offset); if ( prefix.endsWith("/channels/") || prefix.endsWith("/guilds/") || prefix.endsWith("/webhooks/") ) { return match; } return ":id"; }); return `${method.toUpperCase()}:${route}`; } catch { return `${method.toUpperCase()}:${url}`; } } private updateBucketState(routeId: string, response: Response) { const hash = response.headers.get("x-ratelimit-bucket"); const limit = response.headers.get("x-ratelimit-limit"); const remaining = response.headers.get("x-ratelimit-remaining"); const resetAfter = response.headers.get("x-ratelimit-reset-after"); const bucketHash = hash || routeId; if (hash && !this.routeToBucketHash.has(routeId)) { this.debug("discovered bucket hash map", { routeId, bucketHash }); this.routeToBucketHash.set(routeId, hash); } let bucket = this.buckets.get(bucketHash); const tempBucket = this.buckets.get(routeId); if (!bucket) { if (tempBucket && hash) { bucket = tempBucket; this.buckets.set(hash, bucket); this.buckets.delete(routeId); } else { bucket = { limit: 1, remaining: 1, resetTime: 0, queue: Promise.resolve() }; this.buckets.set(bucketHash, bucket); } } else if (tempBucket && tempBucket !== bucket) { const oldHashQueue = bucket.queue; bucket.queue = Promise.allSettled([oldHashQueue, tempBucket.queue]).then(() => {}); this.buckets.delete(routeId); } if (limit !== null) bucket.limit = Number.parseInt(limit, 10); if (remaining !== null) bucket.remaining = Number.parseInt(remaining, 10); if (resetAfter !== null) bucket.resetTime = Date.now() + Number.parseFloat(resetAfter) * 1000; this.debug("updated bucket rate limit state", { bucketHash, limit: bucket.limit, remaining: bucket.remaining, resetAfterSeconds: resetAfter !== null ? Number.parseFloat(resetAfter) : undefined, }); return bucket; } public fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const method = init?.method || "GET"; const urlStr = input.toString(); const routeId = this.getRouteId(method, urlStr); const bucketHash = this.routeToBucketHash.get(routeId) || routeId; let bucket = this.buckets.get(bucketHash); if (!bucket) { bucket = { limit: 1, remaining: 1, resetTime: 0, queue: Promise.resolve() }; this.buckets.set(bucketHash, bucket); } this.debug("queuing request", { method, routeId, bucketHash }); return new Promise((resolve, reject) => { bucket.queue = bucket.queue.then(async () => { try { const globalDelayMs = this.globalResetTime - Date.now(); if (globalDelayMs > 0) { this.debug("waiting for global rate limit to clear", { routeId, delayMs: globalDelayMs, }); await sleep(globalDelayMs); } const currentBucketHash = this.routeToBucketHash.get(routeId) || routeId; const currentBucket = this.buckets.get(currentBucketHash) || bucket; const now = Date.now(); if (currentBucket.remaining <= 0 && now < currentBucket.resetTime) { const delay = currentBucket.resetTime - now + 50; this.options.onRateLimit?.({ routeId, bucketHash: currentBucketHash, global: false, delayMs: delay, }); this.debug("pre-empt rate limit delay", { routeId, bucketHash: currentBucketHash, delayMs: delay, }); await sleep(delay); } this.debug("executing request", { method, routeId }); let response = await globalThis.fetch(input, init); this.updateBucketState(routeId, response); if (response.status === 429) { let isGlobal = response.headers.get("x-ratelimit-global") === "true"; let retryAfterMs = 5000; const retryAfterHeader = response.headers.get("retry-after"); if (retryAfterHeader) retryAfterMs = Number.parseFloat(retryAfterHeader) * 1000; try { const cloned = response.clone(); const body = await cloned.json(); if (body && typeof body === "object") { if (typeof body.retry_after === "number") retryAfterMs = body.retry_after * 1000; if (typeof body.global === "boolean") isGlobal = body.global; } } catch {} this.options.onRateLimit?.({ routeId, bucketHash: currentBucketHash, global: isGlobal, delayMs: retryAfterMs, }); this.debug("unexpected 429 hit", { routeId, isGlobal, delayMs: retryAfterMs }); if (isGlobal) { this.globalResetTime = Math.max(this.globalResetTime, Date.now() + retryAfterMs); await sleep(this.globalResetTime - Date.now()); } else { await sleep(retryAfterMs); } this.debug("retrying request after 429", { method, routeId }); response = await globalThis.fetch(input, init); this.updateBucketState(routeId, response); } resolve(response); } catch (error) { reject(error); } }); }); }; }