/**
* Rate limiting with exponential backoff
*
* Provides per-source rate limiting to prevent API abuse.
* Uses sliding window algorithm with configurable limits.
*/
import { createLogger } from './logger.js';
import { RateLimitError } from './errors.js';
const logger = createLogger('rate-limiter');
interface RateLimitConfig {
/** Maximum requests allowed in the window */
requestsPerWindow: number;
/** Window duration in milliseconds */
windowMs: number;
}
interface RateLimitState {
requests: number[];
backoffUntil: number;
consecutiveFailures: number;
}
const DEFAULT_CONFIG: RateLimitConfig = {
requestsPerWindow: 60,
windowMs: 60_000, // 1 minute
};
/**
* Rate limiter with per-source tracking and exponential backoff
*/
export class RateLimiter {
private readonly configs = new Map<string, RateLimitConfig>();
private readonly state = new Map<string, RateLimitState>();
/**
* Configure rate limits for a specific source
*/
configure(source: string, config: Partial<RateLimitConfig>): void {
this.configs.set(source, { ...DEFAULT_CONFIG, ...config });
logger.debug('Configured rate limit', { source, ...this.configs.get(source) });
}
/**
* Get current config for a source
*/
getConfig(source: string): RateLimitConfig {
return this.configs.get(source) ?? DEFAULT_CONFIG;
}
/**
* Check if a request is allowed and record it
*/
async checkLimit(source: string): Promise<void> {
const config = this.getConfig(source);
const state = this.getState(source);
const now = Date.now();
// Check backoff
if (state.backoffUntil > now) {
const retryAfter = Math.ceil((state.backoffUntil - now) / 1000);
logger.warning('Rate limit backoff active', { source, retryAfter });
throw new RateLimitError(retryAfter);
}
// Clean old requests outside window
const windowStart = now - config.windowMs;
state.requests = state.requests.filter((time) => time > windowStart);
// Check limit
if (state.requests.length >= config.requestsPerWindow) {
const oldestRequest = state.requests[0] ?? now;
const retryAfter = Math.ceil((oldestRequest + config.windowMs - now) / 1000);
logger.warning('Rate limit exceeded', { source, retryAfter });
throw new RateLimitError(retryAfter);
}
// Record request
state.requests.push(now);
}
/**
* Wait until a slot is available (non-blocking check)
*/
async waitForSlot(source: string): Promise<void> {
const config = this.getConfig(source);
const state = this.getState(source);
const now = Date.now();
// Check backoff first
if (state.backoffUntil > now) {
const waitMs = state.backoffUntil - now;
logger.debug('Waiting for backoff', { source, waitMs });
await this.sleep(waitMs);
}
// Clean old requests
const windowStart = now - config.windowMs;
state.requests = state.requests.filter((time) => time > windowStart);
// Wait if at limit
if (state.requests.length >= config.requestsPerWindow) {
const oldestRequest = state.requests[0] ?? now;
const waitMs = oldestRequest + config.windowMs - now + 100; // Small buffer
logger.debug('Waiting for rate limit slot', { source, waitMs });
await this.sleep(waitMs);
}
// Record request
state.requests.push(Date.now());
}
/**
* Record a failure and apply backoff
*/
recordFailure(source: string): void {
const state = this.getState(source);
state.consecutiveFailures++;
// Exponential backoff: 2^failures seconds, max 60 seconds
const backoffSeconds = Math.min(Math.pow(2, state.consecutiveFailures), 60);
state.backoffUntil = Date.now() + backoffSeconds * 1000;
logger.warning('Recorded failure, applying backoff', {
source,
consecutiveFailures: state.consecutiveFailures,
backoffSeconds,
});
}
/**
* Record a success and reset failure count
*/
recordSuccess(source: string): void {
const state = this.getState(source);
if (state.consecutiveFailures > 0) {
logger.debug('Resetting failure count', { source });
state.consecutiveFailures = 0;
state.backoffUntil = 0;
}
}
/**
* Get remaining requests in current window
*/
getRemainingRequests(source: string): number {
const config = this.getConfig(source);
const state = this.getState(source);
const now = Date.now();
const windowStart = now - config.windowMs;
const activeRequests = state.requests.filter((time) => time > windowStart).length;
return Math.max(0, config.requestsPerWindow - activeRequests);
}
/**
* Reset all rate limits (useful for testing)
*/
reset(): void {
this.state.clear();
logger.debug('Reset all rate limits');
}
private getState(source: string): RateLimitState {
let state = this.state.get(source);
if (!state) {
state = {
requests: [],
backoffUntil: 0,
consecutiveFailures: 0,
};
this.state.set(source, state);
}
return state;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// Global rate limiter instance
let globalRateLimiter: RateLimiter | null = null;
/**
* Get the global rate limiter instance
*/
export function getRateLimiter(): RateLimiter {
if (!globalRateLimiter) {
globalRateLimiter = new RateLimiter();
}
return globalRateLimiter;
}
/**
* Reset the global rate limiter (for testing)
*/
export function resetRateLimiter(): void {
globalRateLimiter = null;
}