interface FailedMeta { attempts: number; lastFailedAt: number; } const COOLDOWN_SCHEDULE_MS = [5 * 60 * 1000, 10 * 60 * 1000, 20 * 60 * 1000, 30 * 60 * 1000]; export class OAuthReconnectionTracker { private failedMeta: Map> = new Map(); /** Map of userId -> Set of serverNames that are actively reconnecting */ private active: Map> = new Map(); /** Map of userId:serverName -> timestamp when reconnection started */ private activeTimestamps: Map = new Map(); /** Maximum time (ms) a server can be in reconnecting state before auto-cleanup */ private readonly RECONNECTION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes public isFailed(userId: string, serverName: string): boolean { const meta = this.failedMeta.get(userId)?.get(serverName); if (!meta) { return false; } const idx = Math.min(meta.attempts - 1, COOLDOWN_SCHEDULE_MS.length - 1); const cooldown = COOLDOWN_SCHEDULE_MS[idx]; const elapsed = Date.now() - meta.lastFailedAt; if (elapsed >= cooldown) { return false; } return true; } /** Check if server is in the active set (original simple check) */ public isActive(userId: string, serverName: string): boolean { return this.active.get(userId)?.has(serverName) ?? false; } /** Check if server is still reconnecting (considers timeout) */ public isStillReconnecting(userId: string, serverName: string): boolean { if (!this.isActive(userId, serverName)) { return false; } const key = `${userId}:${serverName}`; const startTime = this.activeTimestamps.get(key); // If there's a timestamp and it has timed out, it's not still reconnecting if (startTime && Date.now() - startTime > this.RECONNECTION_TIMEOUT_MS) { return false; } return true; } /** Clean up server if it has timed out - returns true if cleanup was performed */ public cleanupIfTimedOut(userId: string, serverName: string): boolean { const key = `${userId}:${serverName}`; const startTime = this.activeTimestamps.get(key); if (startTime && Date.now() - startTime > this.RECONNECTION_TIMEOUT_MS) { this.removeActive(userId, serverName); return true; } return false; } public setFailed(userId: string, serverName: string): void { if (!this.failedMeta.has(userId)) { this.failedMeta.set(userId, new Map()); } const userMap = this.failedMeta.get(userId)!; const existing = userMap.get(serverName); userMap.set(serverName, { attempts: (existing?.attempts ?? 0) + 1, lastFailedAt: Date.now(), }); } public setActive(userId: string, serverName: string): void { if (!this.active.has(userId)) { this.active.set(userId, new Set()); } this.active.get(userId)?.add(serverName); /** Track when reconnection started */ const key = `${userId}:${serverName}`; this.activeTimestamps.set(key, Date.now()); } public removeFailed(userId: string, serverName: string): void { const userMap = this.failedMeta.get(userId); userMap?.delete(serverName); if (userMap?.size === 0) { this.failedMeta.delete(userId); } } public removeActive(userId: string, serverName: string): void { const userServers = this.active.get(userId); userServers?.delete(serverName); if (userServers?.size === 0) { this.active.delete(userId); } /** Clear timestamp tracking */ const key = `${userId}:${serverName}`; this.activeTimestamps.delete(key); } /** Returns map sizes for diagnostics */ public getStats(): { usersWithFailedServers: number; usersWithActiveReconnections: number; activeTimestamps: number; } { return { usersWithFailedServers: this.failedMeta.size, usersWithActiveReconnections: this.active.size, activeTimestamps: this.activeTimestamps.size, }; } }