87 lines
3.0 KiB
TypeScript
87 lines
3.0 KiB
TypeScript
/**
|
|
* Tiny in-process fixed-window rate limiter for the Smart Gallery AI routes.
|
|
*
|
|
* ---------------------------------------------------------------------------
|
|
* SCOPE AND LIMITATIONS — READ BEFORE RELYING ON THIS
|
|
* ---------------------------------------------------------------------------
|
|
* State lives in a plain `Map` in THIS process's memory. That means:
|
|
*
|
|
* - PER-INSTANCE, NOT GLOBAL. With N app instances behind a load balancer a
|
|
* caller gets up to N x the configured budget. On serverless platforms each
|
|
* cold start begins with an empty map, so the effective limit is weaker
|
|
* still.
|
|
* - NOT DURABLE. A restart or redeploy clears every counter.
|
|
* - FIXED WINDOW, NOT SLIDING. A caller can burst `max` at the very end of one
|
|
* window and `max` again at the start of the next — up to 2x `max` across a
|
|
* window boundary. Acceptable here; the goal is to bound runaway cost, not
|
|
* to meter precisely.
|
|
*
|
|
* It exists because the routes it guards spend real money on GPU inference and
|
|
* shipping them with NO limit at all is worse than shipping an imperfect one.
|
|
*
|
|
* REPLACE WITH REDIS (or the platform's rate limiter) BEFORE RUNNING MORE THAN
|
|
* ONE INSTANCE. The `limit()` signature is deliberately narrow so a Redis-backed
|
|
* implementation can drop straight in — the only change needed is making it
|
|
* async at the call sites.
|
|
*
|
|
* This is a throttle, not an authorization check. See src/lib/server/session.ts.
|
|
*/
|
|
|
|
interface Window {
|
|
/** Requests counted so far in the current window. */
|
|
count: number;
|
|
/** Epoch ms at which the current window ends. */
|
|
resetAt: number;
|
|
}
|
|
|
|
const windows = new Map<string, Window>();
|
|
|
|
/** Drop expired entries so the map cannot grow without bound. */
|
|
const SWEEP_INTERVAL_MS = 60_000;
|
|
let lastSweep = 0;
|
|
|
|
function sweep(now: number): void {
|
|
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
|
|
lastSweep = now;
|
|
for (const [key, w] of windows) {
|
|
if (w.resetAt <= now) windows.delete(key);
|
|
}
|
|
}
|
|
|
|
export interface LimitResult {
|
|
ok: boolean;
|
|
/** Seconds until the window resets. Send as `Retry-After` when `ok` is false. */
|
|
retryAfter: number;
|
|
}
|
|
|
|
/**
|
|
* Count one request against `key` and report whether it is allowed.
|
|
*
|
|
* @param key Caller identity — use `rateLimitKey()` from ./session.
|
|
* @param max Requests allowed per window.
|
|
* @param windowMs Window length in ms.
|
|
*/
|
|
export function limit(key: string, max: number, windowMs: number): LimitResult {
|
|
const now = Date.now();
|
|
sweep(now);
|
|
|
|
const existing = windows.get(key);
|
|
if (!existing || existing.resetAt <= now) {
|
|
windows.set(key, { count: 1, resetAt: now + windowMs });
|
|
return { ok: true, retryAfter: 0 };
|
|
}
|
|
|
|
if (existing.count >= max) {
|
|
return { ok: false, retryAfter: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) };
|
|
}
|
|
|
|
existing.count += 1;
|
|
return { ok: true, retryAfter: 0 };
|
|
}
|
|
|
|
/** Test/maintenance helper — clears all counters. */
|
|
export function resetAllLimits(): void {
|
|
windows.clear();
|
|
lastSweep = 0;
|
|
}
|