/**
* Rate Limiter
*
* Distributed rate limiting using Cloudflare KV.
* Enforces rate limits per client to prevent abuse.
*/
import { logger } from './logger.js';
/**
* Rate Limiter Class
*/
export class RateLimiter {
constructor() {
this.defaultWindow = 60; // 60 seconds
this.defaultMaxRequests = 100;
}
/**
* Check if client is rate limited
* @param {string} clientIdentifier - Client identifier (IP or user ID)
* @param {Object} kvStore - Cloudflare KV store
* @param {Object} options - Rate limit options
* @returns {Promise<Object>} Rate limit check result
*/
async check(clientIdentifier, kvStore, options = {}) {
const window = options.window || this.defaultWindow;
const maxRequests = options.maxRequests || this.defaultMaxRequests;
const now = Date.now();
const windowStart = now - (window * 1000);
const key = `rate_limit:${clientIdentifier}`;
try {
// Get current rate limit data from KV
const currentData = await kvStore.get(key, { type: 'json' });
let requestData = currentData || {
requests: [],
count: 0
};
// Filter out old requests outside the time window
requestData.requests = requestData.requests.filter(
timestamp => timestamp > windowStart
);
// Check if limit exceeded
if (requestData.requests.length >= maxRequests) {
// Calculate time until next request is allowed
const oldestRequest = requestData.requests[0];
const retryAfter = Math.ceil((oldestRequest + window * 1000 - now) / 1000);
logger.warn('Rate limit exceeded', {
clientIdentifier,
currentCount: requestData.requests.length,
maxRequests,
retryAfter
});
return {
allowed: false,
limit: maxRequests,
remaining: 0,
reset: oldestRequest + window * 1000,
retryAfter
};
}
// Add current request
requestData.requests.push(now);
requestData.count = requestData.requests.length;
// Update KV with new data
await kvStore.put(key, JSON.stringify(requestData), {
expirationTtl: window
});
const oldestRequest = requestData.requests[0];
const resetTime = oldestRequest + window * 1000;
logger.debug('Rate limit check passed', {
clientIdentifier,
currentCount: requestData.count,
maxRequests,
remaining: maxRequests - requestData.count
});
return {
allowed: true,
limit: maxRequests,
remaining: maxRequests - requestData.count,
reset: resetTime
};
} catch (error) {
// If KV store is unavailable, fail open (allow request)
logger.error('Rate limiter error, failing open', {
clientIdentifier,
error: error.message
});
return {
allowed: true,
limit: maxRequests,
remaining: maxRequests,
reset: Date.now() + window * 1000,
error: 'Rate limiter unavailable'
};
}
}
/**
* Get current rate limit status for a client
* @param {string} clientIdentifier - Client identifier
* @param {Object} kvStore - Cloudflare KV store
* @param {Object} options - Rate limit options
* @returns {Promise<Object>} Rate limit status
*/
async getStatus(clientIdentifier, kvStore, options = {}) {
const window = options.window || this.defaultWindow;
const maxRequests = options.maxRequests || this.defaultMaxRequests;
const now = Date.now();
const windowStart = now - (window * 1000);
const key = `rate_limit:${clientIdentifier}`;
try {
const currentData = await kvStore.get(key, { type: 'json' });
if (!currentData) {
return {
limit: maxRequests,
remaining: maxRequests,
reset: Date.now() + window * 1000
};
}
// Filter out old requests
const validRequests = currentData.requests.filter(
timestamp => timestamp > windowStart
);
const oldestRequest = validRequests[0];
const resetTime = oldestRequest ? oldestRequest + window * 1000 : Date.now() + window * 1000;
return {
limit: maxRequests,
remaining: maxRequests - validRequests.length,
reset: resetTime,
used: validRequests.length
};
} catch (error) {
logger.error('Failed to get rate limit status', {
clientIdentifier,
error: error.message
});
return {
limit: maxRequests,
remaining: maxRequests,
reset: Date.now() + window * 1000,
error: 'Rate limiter unavailable'
};
}
}
/**
* Reset rate limit for a client
* @param {string} clientIdentifier - Client identifier
* @param {Object} kvStore - Cloudflare KV store
* @returns {Promise<void>}
*/
async reset(clientIdentifier, kvStore) {
const key = `rate_limit:${clientIdentifier}`;
try {
await kvStore.delete(key);
logger.info('Rate limit reset', { clientIdentifier });
} catch (error) {
logger.error('Failed to reset rate limit', {
clientIdentifier,
error: error.message
});
}
}
/**
* Reset all rate limits (use with caution)
* @param {Object} kvStore - Cloudflare KV store
* @param {string} prefix - Key prefix to match
* @returns {Promise<void>}
*/
async resetAll(kvStore, prefix = 'rate_limit:') {
logger.warn('Resetting all rate limits', { prefix });
try {
// Note: KV list operation requires list permission
const listed = await kvStore.list({ prefix });
for (const key of listed.keys) {
await kvStore.delete(key.name);
}
logger.info('All rate limits reset', {
count: listed.keys.length
});
} catch (error) {
logger.error('Failed to reset all rate limits', {
prefix,
error: error.message
});
}
}
/**
* Set rate limit configuration
* @param {Object} options - Configuration options
*/
setConfig(options) {
if (options.window) {
this.defaultWindow = options.window;
}
if (options.maxRequests) {
this.defaultMaxRequests = options.maxRequests;
}
logger.info('Rate limiter configuration updated', {
window: this.defaultWindow,
maxRequests: this.defaultMaxRequests
});
}
/**
* Get current configuration
* @returns {Object} Configuration
*/
getConfig() {
return {
window: this.defaultWindow,
maxRequests: this.defaultMaxRequests
};
}
}
// Export singleton instance
export const rateLimiter = new RateLimiter();