Files
RedditBot/src/utils/rate-limiter.ts
T

190 lines
6.7 KiB
TypeScript

import PQueue from 'p-queue';
import type { Config } from '../config/index.js';
import type { Logger } from './logger.js';
const DEFAULT_HOUR_WINDOW_MS = 3600000;
/** Loads timestamps of successful sends made since the supplied epoch time. */
export type SentMessageHistoryProvider = (since: number) => Promise<readonly number[]>;
export interface RateLimiterDependencies {
/**
* Optional durable history used to restore the rolling hourly limit after a
* process restart. The database adapter deliberately lives outside this
* utility so the limiter remains independently testable.
*/
sentMessageHistory?: SentMessageHistoryProvider;
now?: () => number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
random?: () => number;
}
export class RateLimiter {
private queue: PQueue;
private lastMessageTime = 0;
private successfulMessageTimes: number[] = [];
private initialization: Promise<void> | null = null;
private readonly hourWindowMs: number;
private readonly now: () => number;
private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
private readonly random: () => number;
constructor(
private config: Config,
private logger: Logger,
hourWindowMs: number = DEFAULT_HOUR_WINDOW_MS,
dependencies: RateLimiterDependencies = {},
) {
this.queue = new PQueue({ concurrency: 1 });
this.hourWindowMs = hourWindowMs;
this.now = dependencies.now ?? Date.now;
this.sleep = dependencies.sleep ?? abortableSleep;
this.random = dependencies.random ?? Math.random;
if (dependencies.sentMessageHistory) {
this.initialization = this.loadHistory(dependencies.sentMessageHistory);
}
}
/** Wait for durable rate-limit history to be loaded before accepting work. */
async initialize(): Promise<void> {
await this.initialization;
}
async throttle<T>(
fn: () => Promise<T>,
shouldCount: (result: T) => boolean = () => true,
signal?: AbortSignal,
priority = 0,
): Promise<T> {
return this.queue.add(async () => {
await this.initialize();
throwIfAborted(signal);
const now = this.now();
this.pruneHistory(now);
// Enforce the limit over a rolling hour so restarts cannot reset it.
if (this.successfulMessageTimes.length >= this.config.rateLimit.maxMessagesPerHour) {
const oldestSend = this.successfulMessageTimes[0];
const waitTime = Math.max(0, oldestSend + this.hourWindowMs - now);
this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`);
await this.sleep(waitTime, signal);
this.pruneHistory(this.now());
}
// Enforce minimum delay with jitter
// We check against the previously completed message time
const timeSinceLastMessage = now - this.lastMessageTime;
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
const jitter = this.random() * this.config.rateLimit.jitterMs;
if (this.lastMessageTime > 0 && timeSinceLastMessage < minDelay) {
const waitTime = minDelay - timeSinceLastMessage + jitter;
this.logger.debug(`Rate limiting: waiting ${Math.round(waitTime / 1000)} seconds`);
await this.sleep(waitTime, signal);
}
// Execute function
const result = await fn();
// Update stats only if shouldCount returns true
if (shouldCount(result)) {
this.lastMessageTime = this.now();
this.successfulMessageTimes.push(this.lastMessageTime);
}
return result;
}, { priority }) as Promise<T>;
}
private async loadHistory(provider: SentMessageHistoryProvider): Promise<void> {
const now = this.now();
const timestamps = await provider(now - this.hourWindowMs);
this.successfulMessageTimes = timestamps
.filter((timestamp) => Number.isFinite(timestamp) && timestamp > now - this.hourWindowMs && timestamp <= now)
.sort((a, b) => a - b);
this.lastMessageTime = this.successfulMessageTimes.at(-1) ?? 0;
}
private pruneHistory(now: number): void {
const cutoff = now - this.hourWindowMs;
const before = this.successfulMessageTimes.length;
this.successfulMessageTimes = this.successfulMessageTimes.filter((timestamp) => timestamp > cutoff);
if (before !== this.successfulMessageTimes.length) {
this.logger.debug('Hourly rate limit counter reset');
}
}
getStats(): { messagesThisHour: number; minutesSinceLastMessage: number } {
this.pruneHistory(this.now());
return {
messagesThisHour: this.successfulMessageTimes.length,
minutesSinceLastMessage: Math.round((this.now() - this.lastMessageTime) / 60000),
};
}
/**
* Get active timer information for the dashboard
*/
getTimers(): {
nextMessageAvailableAt: number | null;
hourlyResetAt: number;
isHourlyLimitReached: boolean;
messagesRemaining: number;
} {
const now = this.now();
this.pruneHistory(now);
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
const maxMessages = this.config.rateLimit.maxMessagesPerHour;
// Calculate when the next message can be sent based on min delay
let nextMessageAvailableAt: number | null = null;
if (this.lastMessageTime > 0) {
const nextAvailable = this.lastMessageTime + minDelay;
if (nextAvailable > now) {
nextMessageAvailableAt = nextAvailable;
}
}
// Calculate when the hourly counter resets
const hourlyResetAt = (this.successfulMessageTimes[0] ?? now) + this.hourWindowMs;
// Check if hourly limit is reached
const isHourlyLimitReached = this.successfulMessageTimes.length >= maxMessages;
// If hourly limit is reached, next message is available when the hour resets
if (isHourlyLimitReached) {
nextMessageAvailableAt = hourlyResetAt;
}
return {
nextMessageAvailableAt,
hourlyResetAt,
isHourlyLimitReached,
messagesRemaining: Math.max(0, maxMessages - this.successfulMessageTimes.length),
};
}
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(new DOMException('Operation aborted', 'AbortError'));
return new Promise((resolve, reject) => {
let timer: NodeJS.Timeout;
const onAbort = (): void => {
clearTimeout(timer);
cleanup();
reject(new DOMException('Operation aborted', 'AbortError'));
};
const cleanup = (): void => signal?.removeEventListener('abort', onAbort);
const cleanupAndResolve = (): void => {
cleanup();
resolve();
};
timer = setTimeout(cleanupAndResolve, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}