all repos — kitten @ 824d74beeb6ac650590e198387fc00adf368e6cd

ratelimit: init ratelimiting plugin
vi did:web:vt3e.cat
Fri, 19 Jun 2026 11:00:28 +0100
commit

824d74beeb6ac650590e198387fc00adf368e6cd

parent

3ef01075f6f81067a08717f037cbb9b226e194a7

A pkgs/ratelimit/.gitignore

@@ -0,0 +1,34 @@

+# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store
A pkgs/ratelimit/README.md

@@ -0,0 +1,15 @@

+# @purrkit/ratelimit + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + +This project was created using `bun init` in bun v1.3.13. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
A pkgs/ratelimit/package.json

@@ -0,0 +1,11 @@

+{ + "name": "@purrkit/ratelimit", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +}
A pkgs/ratelimit/src/index.ts

@@ -0,0 +1,186 @@

+import type { RateLimitBucket, RateLimitOptions } from "./types"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class RateLimitManager { + private buckets = new Map<string, RateLimitBucket>(); + private routeToBucketHash = new Map<string, string>(); + private globalResetTime = 0; + private options: RateLimitOptions; + private baseUrl: string | undefined; + + constructor(options: RateLimitOptions = {}) { + this.options = options; + } + + private debug(message: string, data?: Record<string, unknown>) { + 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<Response> => { + 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<Response>((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); + } + }); + }); + }; +}
A pkgs/ratelimit/src/types.ts

@@ -0,0 +1,17 @@

+export interface RateLimitBucket { + limit: number; + remaining: number; + resetTime: number; + queue: Promise<void>; +} + +export interface RateLimitOptions { + baseUrl?: string; + onDebug?: (message: string, data?: Record<string, unknown>) => void; + onRateLimit?: (data: { + routeId: string; + bucketHash: string; + global: boolean; + delayMs: number; + }) => void; +}
A pkgs/ratelimit/tsconfig.json

@@ -0,0 +1,30 @@

+{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["DOM", "ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}