功能说明
该示例实现按 key 计数的固定窗口限流器,每个窗口内累计消耗的成本不能超过 limit。consume 返回是否放行、剩余额度、窗口重置时间以及被拒绝时的建议等待毫秒数,可直接映射为 429 响应与 Retry-After 头。适合 API 网关、登录尝试限制、单用户导出配额等进程内限流场景。
关键代码
状态用 Map 按 key 保存计数与窗口结束时间。访问时若条目不存在或已过期,就创建新窗口;创建前若键数量达到 maxKeys,先清理所有过期窗口,仍满则按插入顺序删除最旧的键,避免恶意 key 无限增长。成本必须是正整数,允许一次操作扣多个额度。窗口内累计成本不超过 limit 时放行并累加;否则保持计数不变并返回剩余等待时间。
使用注意
固定窗口在边界处可能产生双倍突发:前一窗口末尾与后一窗口开头各放行一批请求,需要更平滑的限流时应改用滑动窗口或令牌桶。该实现是单进程内存状态,多实例部署需换成 Redis 等共享存储并处理原子性。状态按插入顺序淘汰,不按最近访问时间,热点键与冷键混用时需评估 maxKeys 大小。reset 可用于测试或管理端解封。
验证方式
用可注入时钟测试:同一窗口内前两次成本为一的请求放行,第三次超限时返回 allowed 为 false 与正确的 retryAfterMs;推进时钟越过重置点后额度恢复;测试一次成本大于 limit 永远拒绝;连续创建超过 maxKeys 的键,断言 Map 大小受限且过期键被优先清除;非法 limit、windowMs 与成本抛 RangeError。
代码示例
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
retryAfterMs: number;
}
export interface RateLimiterOptions {
limit: number;
windowMs: number;
now?: () => number;
maxKeys?: number;
}
interface WindowState {
count: number;
resetAt: number;
}
export class FixedWindowRateLimiter {
private readonly limit: number;
private readonly windowMs: number;
private readonly now: () => number;
private readonly maxKeys: number;
private readonly windows = new Map<string, WindowState>();
constructor(options: RateLimiterOptions) {
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new RangeError("limit must be a positive integer");
}
if (!Number.isFinite(options.windowMs) || options.windowMs <= 0) {
throw new RangeError("windowMs must be a positive finite number");
}
const maxKeys = options.maxKeys ?? 10_000;
if (!Number.isInteger(maxKeys) || maxKeys < 1) {
throw new RangeError("maxKeys must be a positive integer");
}
this.limit = options.limit;
this.windowMs = options.windowMs;
this.now = options.now ?? Date.now;
this.maxKeys = maxKeys;
}
consume(key: string, cost: number = 1): RateLimitResult {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("key must be a non-empty string");
}
if (!Number.isInteger(cost) || cost < 1) {
throw new RangeError("cost must be a positive integer");
}
const now = this.now();
if (!Number.isFinite(now)) throw new RangeError("now must return a finite timestamp");
let state = this.windows.get(key);
if (!state || now >= state.resetAt) {
if (!state && this.windows.size >= this.maxKeys) this.evict(now);
state = { count: 0, resetAt: now + this.windowMs };
this.windows.set(key, state);
}
const allowed = state.count + cost <= this.limit;
if (allowed) state.count += cost;
return {
allowed,
remaining: Math.max(0, this.limit - state.count),
resetAt: state.resetAt,
retryAfterMs: allowed ? 0 : Math.max(0, state.resetAt - now),
};
}
reset(key?: string): void {
if (key === undefined) this.windows.clear();
else this.windows.delete(key);
}
get size(): number {
return this.windows.size;
}
private evict(now: number): void {
for (const [key, state] of this.windows) {
if (now >= state.resetAt) this.windows.delete(key);
}
if (this.windows.size < this.maxKeys) return;
const oldest = this.windows.keys().next();
if (!oldest.done) this.windows.delete(oldest.value);
}
}