Assist_Design/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts

290 lines
9.8 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis";
import { randomUUID } from "crypto";
export type OperationType = "voice" | "network" | "plan" | "cancellation";
interface OperationTimestamps {
voice?: number;
network?: number;
plan?: number;
cancellation?: number;
}
/**
* Rate limiter for Freebit API operations.
*
* Enforces timing constraints between operations to prevent them from
* canceling each other:
* - PA05-06 (voice features): Runs with immediate effect
* - PA05-38 (contract line): Runs with immediate effect
* - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month
* - PA05-21 and PA02-04 (cancellation) cannot coexist
* - These must run 30 minutes apart to avoid canceling each other
*/
@Injectable()
export class FreebitRateLimiterService {
private readonly operationTimestamps = new Map<string, OperationTimestamps>();
private lastCleanupTime = 0;
private readonly cleanupIntervalMs = 5 * 60 * 1000; // Run cleanup at most every 5 minutes
private readonly staleThresholdMs = 35 * 60 * 1000; // Remove entries older than 35 minutes
private readonly windowMs = 30 * 60 * 1000; // 30 minute window between operations
private readonly lockTtlMs = Math.min(this.windowMs, 10 * 60 * 1000);
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Cleanup stale entries from operationTimestamps to prevent memory leak.
* Entries are considered stale when all their timestamps are older than staleThresholdMs.
*/
private cleanupStaleEntries(): void {
const now = Date.now();
// Rate limit cleanup to avoid performance overhead
if (now - this.lastCleanupTime < this.cleanupIntervalMs) {
return;
}
this.lastCleanupTime = now;
const staleThreshold = now - this.staleThresholdMs;
const accountsToRemove: string[] = [];
for (const [account, entry] of this.operationTimestamps) {
const timestamps = [entry.voice, entry.network, entry.plan, entry.cancellation].filter(
(t): t is number => typeof t === "number"
);
// If all timestamps are stale (or entry is empty), mark for removal
// EXCEPT: Never clean up entries with a cancellation - cancellation blocks plan changes permanently
// until the account is actually cancelled or the cancellation is reverted externally
const hasCancellation = typeof entry.cancellation === "number";
if (
!hasCancellation &&
(timestamps.length === 0 || timestamps.every(t => t < staleThreshold))
) {
accountsToRemove.push(account);
}
}
for (const account of accountsToRemove) {
this.operationTimestamps.delete(account);
}
if (accountsToRemove.length > 0) {
this.logger.debug(`Cleaned up ${accountsToRemove.length} stale operation timestamp entries`);
}
}
/**
* Get operation timestamps for an account, initializing if needed.
*/
private getOperationWindowFromMemory(account: string): OperationTimestamps {
this.cleanupStaleEntries();
if (!this.operationTimestamps.has(account)) {
this.operationTimestamps.set(account, {});
}
return this.operationTimestamps.get(account)!;
}
private buildKey(account: string): string {
return `freebit:ops:${account}`;
}
private buildLockKey(account: string): string {
return `freebit:ops:lock:${account}`;
}
private async acquireLock(account: string, token: string): Promise<boolean> {
const key = this.buildLockKey(account);
const result = await this.redis.set(key, token, "PX", this.lockTtlMs, "NX");
return result === "OK";
}
private async releaseLock(account: string, token: string): Promise<void> {
const key = this.buildLockKey(account);
const script =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
try {
await this.redis.eval(script, 1, key, token);
} catch (error) {
this.logger.warn("Failed to release Freebit operation lock", {
account,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Execute a Freebit operation with spacing checks and a distributed lock.
* Ensures no concurrent operations violate the 30-minute spacing rule.
*/
async executeWithSpacing<T>(
account: string,
op: OperationType,
operation: () => Promise<T>
): Promise<T> {
const token = randomUUID();
try {
const acquired = await this.acquireLock(account, token);
if (!acquired) {
throw new BadRequestException(
"Another SIM operation is in progress. Please try again shortly."
);
}
await this.assertOperationSpacing(account, op);
const result = await operation();
await this.stampOperation(account, op);
return result;
} finally {
await this.releaseLock(account, token);
}
}
private async getOperationWindow(account: string): Promise<OperationTimestamps> {
const key = this.buildKey(account);
try {
const raw = await this.redis.hgetall(key);
const parsed: OperationTimestamps = {};
for (const [field, value] of Object.entries(raw)) {
const num = Number(value);
if (!isNaN(num)) {
parsed[field as keyof OperationTimestamps] = num;
}
}
return parsed;
} catch (error) {
this.logger.warn("Failed to read Freebit rate limit window from Redis", {
account,
error: error instanceof Error ? error.message : String(error),
});
return this.getOperationWindowFromMemory(account);
}
}
/**
* Assert that an operation can be performed based on timing constraints.
* Throws BadRequestException if the operation violates timing rules.
*/
async assertOperationSpacing(account: string, op: OperationType): Promise<void> {
const now = Date.now();
const entry = await this.getOperationWindow(account);
if (op === "voice") {
if (entry.plan && now - entry.plan < this.windowMs) {
throw new BadRequestException(
"Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later."
);
}
if (entry.network && now - entry.network < this.windowMs) {
throw new BadRequestException(
"Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later."
);
}
}
if (op === "network") {
if (entry.voice && now - entry.voice < this.windowMs) {
throw new BadRequestException(
"Network type updates must be requested 30 minutes after voice option changes. Please try again later."
);
}
if (entry.plan && now - entry.plan < this.windowMs) {
throw new BadRequestException(
"Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later."
);
}
}
if (op === "plan") {
if (entry.voice && now - entry.voice < this.windowMs) {
throw new BadRequestException(
"Plan changes must be requested 30 minutes after voice option changes. Please try again later."
);
}
if (entry.network && now - entry.network < this.windowMs) {
throw new BadRequestException(
"Plan changes must be requested 30 minutes after network type updates. Please try again later."
);
}
if (entry.cancellation) {
throw new BadRequestException(
"This subscription has a pending cancellation. Plan changes are no longer permitted."
);
}
}
}
/**
* Clear all rate limit timestamps for an account (for testing/debugging)
*/
async clearRateLimitForAccount(account: string): Promise<void> {
const key = this.buildKey(account);
try {
await this.redis.del(key);
this.operationTimestamps.delete(account);
this.logger.log(`Cleared rate limit state for account ${account}`);
} catch (error) {
this.logger.warn("Failed to clear rate limit state", {
account,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Get remaining wait time for a specific operation type (in seconds)
*/
async getRemainingWaitTime(account: string, op: OperationType): Promise<number> {
const entry = await this.getOperationWindow(account);
const now = Date.now();
if (op === "network") {
const voiceWait = entry.voice ? Math.max(0, this.windowMs - (now - entry.voice)) : 0;
const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0;
return Math.ceil(Math.max(voiceWait, planWait) / 1000);
}
if (op === "voice") {
const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0;
const networkWait = entry.network ? Math.max(0, this.windowMs - (now - entry.network)) : 0;
return Math.ceil(Math.max(planWait, networkWait) / 1000);
}
return 0;
}
/**
* Record that an operation was performed for an account.
*/
async stampOperation(account: string, op: OperationType): Promise<void> {
const now = Date.now();
const entry = this.getOperationWindowFromMemory(account);
entry[op] = now;
const key = this.buildKey(account);
try {
await this.redis.hset(key, op, String(now));
const hasCancellation = op === "cancellation" || entry.cancellation !== undefined;
if (hasCancellation) {
await this.redis.persist(key);
} else {
await this.redis.expire(key, Math.ceil(this.staleThresholdMs / 1000));
}
} catch (error) {
this.logger.warn("Failed to persist Freebit rate limit window to Redis", {
account,
op,
error: error instanceof Error ? error.message : String(error),
});
}
}
}