tubearr/src/services/rate-limiter.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

133 lines
3.8 KiB
TypeScript

import type { Platform } from '../types/index';
// ── Types ──
export interface RateLimiterConfig {
minIntervalMs: number;
}
export interface PlatformRateLimitState {
lastCallTime: number | null;
errorCount: number;
effectiveIntervalMs: number;
}
// ── Constants ──
/** Maximum backoff interval — 60 seconds. */
const MAX_BACKOFF_MS = 60_000;
// ── Rate Limiter ──
/**
* Per-platform rate limiter with exponential backoff.
*
* `acquire(platform)` waits until the minimum interval has elapsed since the
* last call for that platform. `reportError` doubles the effective interval
* (up to MAX_BACKOFF_MS). `reportSuccess` resets to the configured minimum.
*/
export class RateLimiter {
private readonly config: Record<string, RateLimiterConfig>;
private readonly state = new Map<string, PlatformRateLimitState>();
constructor(config: Partial<Record<Platform, RateLimiterConfig>>) {
this.config = config as Record<string, RateLimiterConfig>;
// Initialize state for each configured platform
for (const [platform, cfg] of Object.entries(this.config)) {
this.state.set(platform, {
lastCallTime: null,
errorCount: 0,
effectiveIntervalMs: cfg.minIntervalMs,
});
}
}
/**
* Wait until enough time has elapsed since the last call for this platform.
* Updates lastCallTime after the wait completes.
*/
async acquire(platform: Platform): Promise<void> {
const state = this.getOrCreateState(platform);
const now = Date.now();
if (state.lastCallTime !== null) {
const elapsed = now - state.lastCallTime;
const remaining = state.effectiveIntervalMs - elapsed;
if (remaining > 0) {
console.log(
`[rate-limiter] ${platform}: waiting ${remaining}ms (effective interval: ${state.effectiveIntervalMs}ms)`
);
await delay(remaining);
}
}
state.lastCallTime = Date.now();
}
/**
* Report a rate-limit or transient error for a platform.
* Doubles the effective interval (exponential backoff) up to MAX_BACKOFF_MS.
*/
reportError(platform: Platform): void {
const state = this.getOrCreateState(platform);
state.errorCount++;
const cfg = this.config[platform];
const baseInterval = cfg?.minIntervalMs ?? 1000;
state.effectiveIntervalMs = Math.min(
baseInterval * Math.pow(2, state.errorCount),
MAX_BACKOFF_MS
);
console.log(
`[rate-limiter] ${platform}: backoff — errorCount=${state.errorCount}, effectiveInterval=${state.effectiveIntervalMs}ms`
);
}
/**
* Report a successful call for a platform.
* Resets error count and effective interval to the configured minimum.
*/
reportSuccess(platform: Platform): void {
const state = this.getOrCreateState(platform);
const cfg = this.config[platform];
const baseInterval = cfg?.minIntervalMs ?? 1000;
state.errorCount = 0;
state.effectiveIntervalMs = baseInterval;
}
/**
* Get the current state of all platforms for diagnostic inspection.
*/
getState(): Record<string, PlatformRateLimitState> {
const result: Record<string, PlatformRateLimitState> = {};
for (const [platform, state] of this.state) {
result[platform] = { ...state };
}
return result;
}
// ── Internal ──
private getOrCreateState(platform: Platform): PlatformRateLimitState {
let state = this.state.get(platform);
if (!state) {
const cfg = this.config[platform];
const minInterval = cfg?.minIntervalMs ?? 1000;
state = {
lastCallTime: null,
errorCount: 0,
effectiveIntervalMs: minInterval,
};
this.state.set(platform, state);
}
return state;
}
}
// ── Helpers ──
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}