feat: replace browser login with imported Reddit sessions
This commit is contained in:
1 parent
2b0545afcf
commit
c483c7b8be
76 files changed
+2048
-2828
No files matched your search
+99
-30
@@ -4,52 +4,82 @@ 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: number = 0;
|
||||
private messagesThisHour: number = 0;
|
||||
private hourStartTime: number = Date.now();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async throttle<T>(fn: () => Promise<T>, shouldCount: (result: T) => boolean = () => true): Promise<T> {
|
||||
/** 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,
|
||||
): Promise<T> {
|
||||
return this.queue.add(async () => {
|
||||
const now = Date.now();
|
||||
await this.initialize();
|
||||
throwIfAborted(signal);
|
||||
const now = this.now();
|
||||
this.pruneHistory(now);
|
||||
|
||||
// Reset hourly counter if needed
|
||||
if (now - this.hourStartTime > this.hourWindowMs) {
|
||||
this.messagesThisHour = 0;
|
||||
this.hourStartTime = now;
|
||||
this.logger.debug('Hourly rate limit counter reset');
|
||||
}
|
||||
|
||||
// Check hourly limit
|
||||
if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) {
|
||||
const waitTime = this.hourWindowMs - (now - this.hourStartTime);
|
||||
// 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);
|
||||
this.messagesThisHour = 0;
|
||||
this.hourStartTime = Date.now();
|
||||
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 = Math.random() * this.config.rateLimit.jitterMs;
|
||||
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);
|
||||
await this.sleep(waitTime, signal);
|
||||
}
|
||||
|
||||
// Execute function
|
||||
@@ -57,22 +87,37 @@ export class RateLimiter {
|
||||
|
||||
// Update stats only if shouldCount returns true
|
||||
if (shouldCount(result)) {
|
||||
this.lastMessageTime = Date.now();
|
||||
this.messagesThisHour++;
|
||||
this.lastMessageTime = this.now();
|
||||
this.successfulMessageTimes.push(this.lastMessageTime);
|
||||
}
|
||||
|
||||
return result;
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
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.messagesThisHour,
|
||||
minutesSinceLastMessage: Math.round((Date.now() - this.lastMessageTime) / 60000),
|
||||
messagesThisHour: this.successfulMessageTimes.length,
|
||||
minutesSinceLastMessage: Math.round((this.now() - this.lastMessageTime) / 60000),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,7 +130,8 @@ export class RateLimiter {
|
||||
isHourlyLimitReached: boolean;
|
||||
messagesRemaining: number;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const now = this.now();
|
||||
this.pruneHistory(now);
|
||||
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
|
||||
const maxMessages = this.config.rateLimit.maxMessagesPerHour;
|
||||
|
||||
@@ -99,10 +145,10 @@ export class RateLimiter {
|
||||
}
|
||||
|
||||
// Calculate when the hourly counter resets
|
||||
const hourlyResetAt = this.hourStartTime + this.hourWindowMs;
|
||||
const hourlyResetAt = (this.successfulMessageTimes[0] ?? now) + this.hourWindowMs;
|
||||
|
||||
// Check if hourly limit is reached
|
||||
const isHourlyLimitReached = this.messagesThisHour >= maxMessages;
|
||||
const isHourlyLimitReached = this.successfulMessageTimes.length >= maxMessages;
|
||||
|
||||
// If hourly limit is reached, next message is available when the hour resets
|
||||
if (isHourlyLimitReached) {
|
||||
@@ -113,7 +159,30 @@ export class RateLimiter {
|
||||
nextMessageAvailableAt,
|
||||
hourlyResetAt,
|
||||
isHourlyLimitReached,
|
||||
messagesRemaining: Math.max(0, maxMessages - this.messagesThisHour),
|
||||
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 });
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user