rateLimit.ts
Language TypeScript3.2 KB80 lines
/*** Rate limiting for the Plinth API.** The API enforces a hard limit of 10 requests / 10 seconds; exceeding it* returns {"error":"Too many requests. Slow down and retry."}. Because a single* model can issue parallel tool calls and every user shares one API budget, the* limit is enforced server-side: every Plinth call passes through a limiter that* delays rather than fails, so heavy pulls slow down instead of erroring. The* client awaits each tool result, so it needs no pacing of its own.** The scheduling math (`reserveSlot`) is a pure function shared by both limiter* implementations:* - InMemoryRateLimiter: a single Node process (the stdio dev entry).* - the Durable-Object-backed limiter on Workers (src/rateLimiterDO.ts),* which routes every session through ONE shared instance so the global* budget holds across all isolates and parallel calls.*//** The Plinth hard cap is 10/10s; schedule to 9/10s to leave headroom. */export const PLINTH_RATE_LIMIT = { max: 9, windowMs: 10_000 } as const;/** Backoff before the single retry when a RateLimitError still slips through. */export const RATE_LIMIT_RETRY_MS = PLINTH_RATE_LIMIT.windowMs;export interface RateLimiter {/** Resolves when it is safe to issue one more request (may delay first). */acquire(): Promise<void>;}export function sleep(ms: number): Promise<void> {return new Promise((resolve) => setTimeout(resolve, ms));}export interface SlotDecision {/** Milliseconds the caller must wait before proceeding (0 = go now). */delayMs: number;/** Reserved-slot timestamps to persist for the next call. */slots: number[];}/*** Pure sliding-window scheduler. Reserves a slot for one new request such that* no `windowMs` interval ever holds more than `max` requests, spreading bursts* across time: each request is scheduled no earlier than `windowMs` after the* `max`-th most recent reservation.** Retains only the most recent `max` reservations (older ones can never* constrain a future request), so the returned array stays bounded under any* burst size.*/export function reserveSlot(slots: readonly number[],now: number,max: number,windowMs: number,): SlotDecision {// Drop reservations that have aged out of the window (they no longer constrain).const live = slots.filter((t) => t > now - windowMs).sort((a, b) => a - b);const reserved =live.length < max ? now : Math.max(now, live[live.length - max]! + windowMs);const next = [...live, reserved].slice(-max);return { delayMs: reserved - now, slots: next };}/** In-memory limiter for a single process (the Node/stdio dev entry). */export class InMemoryRateLimiter implements RateLimiter {private slots: number[] = [];constructor(private readonly max: number = PLINTH_RATE_LIMIT.max,private readonly windowMs: number = PLINTH_RATE_LIMIT.windowMs,) {}async acquire(): Promise<void> {// Reserve synchronously (atomic in a single-threaded runtime) BEFORE the// sleep, so concurrent acquire() calls are spaced apart, not stacked.const { delayMs, slots } = reserveSlot(this.slots, Date.now(), this.max, this.windowMs);this.slots = slots;if (delayMs > 0) await sleep(delayMs);}}