spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1/** Token bucket with async acquisition. Never busy-waits. */2export class TokenBucket {3 private tokens: number;4 private last: number;5 private queue: Array<() => void> = [];6 private timer: NodeJS.Timeout | null = null;78 constructor(9 public ratePerSec: number,10 public burst: number = Math.max(1, Math.ceil(ratePerSec)),11 ) {12 this.tokens = burst;13 this.last = Date.now();14 }1516 private refill() {17 const now = Date.now();18 const delta = (now - this.last) / 1000;19 this.tokens = Math.min(this.burst, this.tokens + delta * this.ratePerSec);20 this.last = now;21 }2223 tryTake(): boolean {24 this.refill();25 if (this.tokens >= 1) {26 this.tokens -= 1;27 return true;28 }29 return false;30 }3132 acquire(): Promise<void> {33 if (this.tryTake()) return Promise.resolve();34 return new Promise((resolve) => {35 this.queue.push(resolve);36 this.schedule();37 });38 }3940 private schedule() {41 if (this.timer) return;42 const waitMs = Math.max(5, ((1 - this.tokens) / this.ratePerSec) * 1000);43 this.timer = setTimeout(() => {44 this.timer = null;45 while (this.queue.length && this.tryTake()) this.queue.shift()!();46 if (this.queue.length) this.schedule();47 }, waitMs);48 }4950 pending(): number {51 return this.queue.length;52 }53}5455/** Central limiter keyed by host. Connectors never sleep on their own. */56export class RateLimiter {57 private buckets = new Map<string, TokenBucket>();58 constructor(private defaults: { ratePerSec: number; burst?: number } = { ratePerSec: 2 }) {}5960 configure(host: string, ratePerSec: number, burst?: number) {61 this.buckets.set(host.toLowerCase(), new TokenBucket(ratePerSec, burst));62 }6364 bucket(host: string): TokenBucket {65 const key = host.toLowerCase();66 let b = this.buckets.get(key);67 if (!b) {68 b = new TokenBucket(this.defaults.ratePerSec, this.defaults.burst);69 this.buckets.set(key, b);70 }71 return b;72 }7374 acquire(host: string): Promise<void> {75 return this.bucket(host).acquire();76 }7778 snapshot(): Array<{ host: string; ratePerSec: number; pending: number }> {79 return [...this.buckets.entries()].map(([host, b]) => ({ host, ratePerSec: b.ratePerSec, pending: b.pending() }));80 }81}8283/** Exponential backoff with full jitter. */84export function backoffMs(attempt: number, baseMs = 1000, maxMs = 5 * 60_000): number {85 const exp = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt));86 return Math.floor(Math.random() * exp);87}88