2025-12-26 18:17:37 +09:00
|
|
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
2025-12-29 15:07:11 +09:00
|
|
|
import type { Redis } from "ioredis";
|
2025-12-29 15:44:01 +09:00
|
|
|
import { randomUUID } from "crypto";
|
2025-12-26 18:17:37 +09:00
|
|
|
|
|
|
|
|
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
|
2025-12-29 15:44:01 +09:00
|
|
|
private readonly lockTtlMs = Math.min(this.windowMs, 10 * 60 * 1000);
|
2025-12-26 18:17:37 +09:00
|
|
|
|
2025-12-29 15:07:11 +09:00
|
|
|
constructor(
|
|
|
|
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
|
|
|
|
@Inject(Logger) private readonly logger: Logger
|
|
|
|
|
) {}
|
2025-12-26 18:17:37 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cleanup stale entries from operationTimestamps to prevent memory leak.
|
|
|
|
|
* Entries are considered stale when all their timestamps are older than staleThresholdMs.
|
|
|
|
|
*/
|
2025-12-29 15:07:11 +09:00
|
|
|
private cleanupStaleEntries(): void {
|
2025-12-26 18:17:37 +09:00
|
|
|
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.
|
|
|
|
|
*/
|
2025-12-29 15:07:11 +09:00
|
|
|
private getOperationWindowFromMemory(account: string): OperationTimestamps {
|
2025-12-26 18:17:37 +09:00
|
|
|
this.cleanupStaleEntries();
|
|
|
|
|
|
|
|
|
|
if (!this.operationTimestamps.has(account)) {
|
|
|
|
|
this.operationTimestamps.set(account, {});
|
|
|
|
|
}
|
|
|
|
|
return this.operationTimestamps.get(account)!;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 15:07:11 +09:00
|
|
|
private buildKey(account: string): string {
|
|
|
|
|
return `freebit:ops:${account}`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 15:44:01 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 15:07:11 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 18:17:37 +09:00
|
|
|
/**
|
|
|
|
|
* Assert that an operation can be performed based on timing constraints.
|
|
|
|
|
* Throws BadRequestException if the operation violates timing rules.
|
|
|
|
|
*/
|
2025-12-29 15:07:11 +09:00
|
|
|
async assertOperationSpacing(account: string, op: OperationType): Promise<void> {
|
2025-12-26 18:17:37 +09:00
|
|
|
const now = Date.now();
|
2025-12-29 15:07:11 +09:00
|
|
|
const entry = await this.getOperationWindow(account);
|
2025-12-26 18:17:37 +09:00
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 17:05:54 +09:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 18:17:37 +09:00
|
|
|
/**
|
|
|
|
|
* Record that an operation was performed for an account.
|
|
|
|
|
*/
|
2025-12-29 15:07:11 +09:00
|
|
|
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),
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-26 18:17:37 +09:00
|
|
|
}
|
|
|
|
|
}
|