@beignet/core
Version:
Core framework primitives for Beignet
129 lines (117 loc) • 3.44 kB
text/typescript
/**
* 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),
};
},
};
}