UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

129 lines (117 loc) 3.44 kB
/** * Input for a single rate-limit hit. */ export interface RateLimitHitOptions { /** * Unique key for this rate limit window. * * Examples: "global", "ip:203.0.113.10", "user:123". */ key: string; /** * Maximum allowed hits inside the window. */ limit: number; /** * Length of the window in seconds. */ windowSec: number; } /** * Result of recording a rate-limit hit. */ export interface RateLimitResult { /** * True when the hit is within the configured limit. */ allowed: boolean; /** * Remaining allowed hits in the window, if known. May be null if the * implementation does not track it. */ remaining: number | null; /** * Date when the window resets, if known. May be null. */ resetAt: Date | null; /** * Seconds until the caller should retry, if the hit was rejected and the * implementation can calculate it. */ retryAfterSeconds: number | null; } /** * App-facing rate limiting port. * * Implement this with an atomic shared store such as Redis for production. * Hook helpers call `hit(...)` to decide whether a request should continue. */ export interface RateLimitPort { /** * Record one hit for a rate-limit key and return the current decision. */ hit(options: RateLimitHitOptions): Promise<RateLimitResult>; } type MemoryRateLimitWindow = { count: number; resetAt: number; }; function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer`); } } function toRetryAfterSeconds(resetAt: number): number { return Math.max(0, Math.ceil((resetAt - Date.now()) / 1000)); } /** * Create an in-memory rate limiter for tests, examples, and single-process * development. * * This adapter is not durable or distributed. Production apps should use a * provider backed by a shared atomic store when multiple processes or regions * can serve requests. * * @returns A rate-limit port backed by a local `Map`. */ export function createMemoryRateLimiter(): RateLimitPort { const windows = new Map<string, MemoryRateLimitWindow>(); const sweepIntervalMs = 60_000; let nextSweepAt = Date.now() + sweepIntervalMs; return { async hit({ key, limit, windowSec }) { assertPositiveInteger("limit", limit); assertPositiveInteger("windowSec", windowSec); const now = Date.now(); // Lazy sweep: distinct keys would otherwise accumulate expired windows // forever. Prune them at most once per sweep interval on the hit path. if (now >= nextSweepAt) { for (const [existingKey, existing] of windows) { if (existing.resetAt <= now) { windows.delete(existingKey); } } nextSweepAt = now + sweepIntervalMs; } const current = windows.get(key); const window = current && current.resetAt > now ? current : { count: 0, resetAt: now + windowSec * 1000, }; window.count += 1; windows.set(key, window); const allowed = window.count <= limit; const remaining = Math.max(0, limit - window.count); const resetAt = new Date(window.resetAt); return { allowed, remaining, resetAt, retryAfterSeconds: allowed ? null : toRetryAfterSeconds(window.resetAt), }; }, }; }