/** Token bucket with async acquisition. Never busy-waits. */ export class TokenBucket { private tokens: number; private last: number; private queue: Array<() => void> = []; private timer: NodeJS.Timeout | null = null; constructor( public ratePerSec: number, public burst: number = Math.max(1, Math.ceil(ratePerSec)), ) { this.tokens = burst; this.last = Date.now(); } private refill() { const now = Date.now(); const delta = (now - this.last) / 1000; this.tokens = Math.min(this.burst, this.tokens + delta * this.ratePerSec); this.last = now; } tryTake(): boolean { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return true; } return false; } acquire(): Promise { if (this.tryTake()) return Promise.resolve(); return new Promise((resolve) => { this.queue.push(resolve); this.schedule(); }); } private schedule() { if (this.timer) return; const waitMs = Math.max(5, ((1 - this.tokens) / this.ratePerSec) * 1000); this.timer = setTimeout(() => { this.timer = null; while (this.queue.length && this.tryTake()) this.queue.shift()!(); if (this.queue.length) this.schedule(); }, waitMs); } pending(): number { return this.queue.length; } } /** Central limiter keyed by host. Connectors never sleep on their own. */ export class RateLimiter { private buckets = new Map(); constructor(private defaults: { ratePerSec: number; burst?: number } = { ratePerSec: 2 }) {} configure(host: string, ratePerSec: number, burst?: number) { this.buckets.set(host.toLowerCase(), new TokenBucket(ratePerSec, burst)); } bucket(host: string): TokenBucket { const key = host.toLowerCase(); let b = this.buckets.get(key); if (!b) { b = new TokenBucket(this.defaults.ratePerSec, this.defaults.burst); this.buckets.set(key, b); } return b; } acquire(host: string): Promise { return this.bucket(host).acquire(); } snapshot(): Array<{ host: string; ratePerSec: number; pending: number }> { return [...this.buckets.entries()].map(([host, b]) => ({ host, ratePerSec: b.ratePerSec, pending: b.pending() })); } } /** Exponential backoff with full jitter. */ export function backoffMs(attempt: number, baseMs = 1000, maxMs = 5 * 60_000): number { const exp = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt)); return Math.floor(Math.random() * exp); }