mattrguerra/meridian-orders-mcpmainPrivate on GitHub, published here in full

rateLimit.ts

Language TypeScript3.2 KB80 lines

mcp-server/src/lib/rateLimit.ts

  1. /**
  2. * Rate limiting for the Plinth API.
  3. *
  4. * The API enforces a hard limit of 10 requests / 10 seconds; exceeding it
  5. * returns {"error":"Too many requests. Slow down and retry."}. Because a single
  6. * model can issue parallel tool calls and every user shares one API budget, the
  7. * limit is enforced server-side: every Plinth call passes through a limiter that
  8. * delays rather than fails, so heavy pulls slow down instead of erroring. The
  9. * client awaits each tool result, so it needs no pacing of its own.
  10. *
  11. * The scheduling math (`reserveSlot`) is a pure function shared by both limiter
  12. * implementations:
  13. * - InMemoryRateLimiter: a single Node process (the stdio dev entry).
  14. * - the Durable-Object-backed limiter on Workers (src/rateLimiterDO.ts),
  15. * which routes every session through ONE shared instance so the global
  16. * budget holds across all isolates and parallel calls.
  17. */
  18. /** The Plinth hard cap is 10/10s; schedule to 9/10s to leave headroom. */
  19. export const PLINTH_RATE_LIMIT = { max: 9, windowMs: 10_000 } as const;
  20. /** Backoff before the single retry when a RateLimitError still slips through. */
  21. export const RATE_LIMIT_RETRY_MS = PLINTH_RATE_LIMIT.windowMs;
  22. export interface RateLimiter {
  23. /** Resolves when it is safe to issue one more request (may delay first). */
  24. acquire(): Promise<void>;
  25. }
  26. export function sleep(ms: number): Promise<void> {
  27. return new Promise((resolve) => setTimeout(resolve, ms));
  28. }
  29. export interface SlotDecision {
  30. /** Milliseconds the caller must wait before proceeding (0 = go now). */
  31. delayMs: number;
  32. /** Reserved-slot timestamps to persist for the next call. */
  33. slots: number[];
  34. }
  35. /**
  36. * Pure sliding-window scheduler. Reserves a slot for one new request such that
  37. * no `windowMs` interval ever holds more than `max` requests, spreading bursts
  38. * across time: each request is scheduled no earlier than `windowMs` after the
  39. * `max`-th most recent reservation.
  40. *
  41. * Retains only the most recent `max` reservations (older ones can never
  42. * constrain a future request), so the returned array stays bounded under any
  43. * burst size.
  44. */
  45. export function reserveSlot(
  46. slots: readonly number[],
  47. now: number,
  48. max: number,
  49. windowMs: number,
  50. ): SlotDecision {
  51. // Drop reservations that have aged out of the window (they no longer constrain).
  52. const live = slots.filter((t) => t > now - windowMs).sort((a, b) => a - b);
  53. const reserved =
  54. live.length < max ? now : Math.max(now, live[live.length - max]! + windowMs);
  55. const next = [...live, reserved].slice(-max);
  56. return { delayMs: reserved - now, slots: next };
  57. }
  58. /** In-memory limiter for a single process (the Node/stdio dev entry). */
  59. export class InMemoryRateLimiter implements RateLimiter {
  60. private slots: number[] = [];
  61. constructor(
  62. private readonly max: number = PLINTH_RATE_LIMIT.max,
  63. private readonly windowMs: number = PLINTH_RATE_LIMIT.windowMs,
  64. ) {}
  65. async acquire(): Promise<void> {
  66. // Reserve synchronously (atomic in a single-threaded runtime) BEFORE the
  67. // sleep, so concurrent acquire() calls are spaced apart, not stacked.
  68. const { delayMs, slots } = reserveSlot(this.slots, Date.now(), this.max, this.windowMs);
  69. this.slots = slots;
  70. if (delayMs > 0) await sleep(delayMs);
  71. }
  72. }