tasq/node_modules/@claude-flow/shared/dist/resilience/rate-limiter.js

314 lines
9.0 KiB
JavaScript

/**
* Rate Limiter
*
* Production-ready rate limiting implementations.
*
* @module v3/shared/resilience/rate-limiter
*/
/**
* Sliding Window Rate Limiter
*
* Uses sliding window algorithm for smooth rate limiting.
*
* @example
* const limiter = new SlidingWindowRateLimiter({
* maxRequests: 100,
* windowMs: 60000, // 100 requests per minute
* });
*
* const result = limiter.consume('user-123');
* if (!result.allowed) {
* throw new Error(`Rate limited. Retry in ${result.retryAfter}ms`);
* }
*/
export class SlidingWindowRateLimiter {
options;
requests = new Map();
cleanupInterval;
constructor(options) {
this.options = {
slidingWindow: true,
...options,
};
// Periodic cleanup of old entries
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, this.options.windowMs);
}
/**
* Check if a request would be allowed without consuming
*/
check(key = 'default') {
this.cleanupKey(key);
const entries = this.requests.get(key) || [];
return {
allowed: entries.length < this.options.maxRequests,
remaining: Math.max(0, this.options.maxRequests - entries.length),
resetAt: this.getResetTime(entries),
retryAfter: this.getRetryAfter(entries),
total: this.options.maxRequests,
used: entries.length,
};
}
/**
* Consume a request token
*/
consume(key = 'default') {
// Clean old entries first
this.cleanupKey(key);
let entries = this.requests.get(key);
if (!entries) {
entries = [];
this.requests.set(key, entries);
}
// Check if allowed
if (entries.length >= this.options.maxRequests) {
const result = {
allowed: false,
remaining: 0,
resetAt: this.getResetTime(entries),
retryAfter: this.getRetryAfter(entries),
total: this.options.maxRequests,
used: entries.length,
};
this.options.onRateLimited?.(key, 0, result.resetAt);
return result;
}
// Add new entry
entries.push({ timestamp: Date.now(), key });
return {
allowed: true,
remaining: this.options.maxRequests - entries.length,
resetAt: this.getResetTime(entries),
retryAfter: 0,
total: this.options.maxRequests,
used: entries.length,
};
}
/**
* Reset rate limit for a key
*/
reset(key) {
if (key) {
this.requests.delete(key);
}
else {
this.requests.clear();
}
}
/**
* Get current status
*/
status(key = 'default') {
return this.check(key);
}
/**
* Cleanup resources
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
this.requests.clear();
}
/**
* Clean old entries for a specific key
*/
cleanupKey(key) {
const entries = this.requests.get(key);
if (!entries)
return;
const cutoff = Date.now() - this.options.windowMs;
const filtered = entries.filter((e) => e.timestamp >= cutoff);
if (filtered.length === 0) {
this.requests.delete(key);
}
else if (filtered.length !== entries.length) {
this.requests.set(key, filtered);
}
}
/**
* Clean all old entries
*/
cleanup() {
for (const key of this.requests.keys()) {
this.cleanupKey(key);
}
}
/**
* Get reset time based on oldest entry
*/
getResetTime(entries) {
if (entries.length === 0) {
return new Date(Date.now() + this.options.windowMs);
}
const oldest = entries[0];
return new Date(oldest.timestamp + this.options.windowMs);
}
/**
* Get retry after time in ms
*/
getRetryAfter(entries) {
if (entries.length < this.options.maxRequests) {
return 0;
}
const oldest = entries[0];
const resetAt = oldest.timestamp + this.options.windowMs;
return Math.max(0, resetAt - Date.now());
}
}
/**
* Token Bucket Rate Limiter
*
* Uses token bucket algorithm for burst-friendly rate limiting.
*
* @example
* const limiter = new TokenBucketRateLimiter({
* maxRequests: 10, // bucket size
* windowMs: 1000, // refill interval
* });
*/
export class TokenBucketRateLimiter {
options;
buckets = new Map();
cleanupInterval;
constructor(options) {
this.options = options;
// Periodic cleanup
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, this.options.windowMs * 10);
}
/**
* Check if a request would be allowed
*/
check(key = 'default') {
this.refill(key);
const bucket = this.getBucket(key);
return {
allowed: bucket.tokens >= 1,
remaining: Math.floor(bucket.tokens),
resetAt: new Date(bucket.lastRefill + this.options.windowMs),
retryAfter: bucket.tokens >= 1 ? 0 : this.options.windowMs,
total: this.options.maxRequests,
used: this.options.maxRequests - Math.floor(bucket.tokens),
};
}
/**
* Consume a token
*/
consume(key = 'default') {
this.refill(key);
const bucket = this.getBucket(key);
if (bucket.tokens < 1) {
const result = {
allowed: false,
remaining: 0,
resetAt: new Date(bucket.lastRefill + this.options.windowMs),
retryAfter: this.options.windowMs,
total: this.options.maxRequests,
used: this.options.maxRequests,
};
this.options.onRateLimited?.(key, 0, result.resetAt);
return result;
}
bucket.tokens -= 1;
return {
allowed: true,
remaining: Math.floor(bucket.tokens),
resetAt: new Date(bucket.lastRefill + this.options.windowMs),
retryAfter: 0,
total: this.options.maxRequests,
used: this.options.maxRequests - Math.floor(bucket.tokens),
};
}
/**
* Reset bucket for a key
*/
reset(key) {
if (key) {
this.buckets.delete(key);
}
else {
this.buckets.clear();
}
}
/**
* Get current status
*/
status(key = 'default') {
return this.check(key);
}
/**
* Cleanup resources
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
this.buckets.clear();
}
/**
* Get or create bucket for key
*/
getBucket(key) {
let bucket = this.buckets.get(key);
if (!bucket) {
bucket = { tokens: this.options.maxRequests, lastRefill: Date.now() };
this.buckets.set(key, bucket);
}
return bucket;
}
/**
* Refill tokens based on elapsed time
*/
refill(key) {
const bucket = this.getBucket(key);
const now = Date.now();
const elapsed = now - bucket.lastRefill;
if (elapsed >= this.options.windowMs) {
// Full refill after window
const intervals = Math.floor(elapsed / this.options.windowMs);
bucket.tokens = Math.min(this.options.maxRequests, bucket.tokens + intervals * this.options.maxRequests);
bucket.lastRefill = now;
}
}
/**
* Clean inactive buckets
*/
cleanup() {
const cutoff = Date.now() - this.options.windowMs * 10;
for (const [key, bucket] of this.buckets) {
if (bucket.lastRefill < cutoff && bucket.tokens >= this.options.maxRequests) {
this.buckets.delete(key);
}
}
}
}
/**
* Rate limiter middleware for Express-like frameworks
*/
export function createRateLimiterMiddleware(limiter) {
return (req, res, next) => {
// Get key from IP or header
const key = req.ip || req.headers?.['x-forwarded-for'] || 'anonymous';
const result = limiter.consume(key);
// Set rate limit headers
res.setHeader('X-RateLimit-Limit', String(result.total));
res.setHeader('X-RateLimit-Remaining', String(result.remaining));
res.setHeader('X-RateLimit-Reset', String(Math.ceil(result.resetAt.getTime() / 1000)));
if (!result.allowed) {
res.setHeader('Retry-After', String(Math.ceil(result.retryAfter / 1000)));
res.status(429).json({
error: 'Too Many Requests',
retryAfter: result.retryAfter,
resetAt: result.resetAt.toISOString(),
});
return;
}
next();
};
}
//# sourceMappingURL=rate-limiter.js.map