Assist_Design/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts
barsa 03eccd1db2 Enhance Error Handling and Logging Consistency Across Services
- Replaced instances of `getErrorMessage` with `extractErrorMessage` in various services to ensure consistent error handling and improve clarity in logging.
- Updated error logging in the Whmcs, Salesforce, and subscription services to utilize the new error extraction method, enhancing maintainability and debugging capabilities.
- Adjusted ESLint configuration to prevent the use of `console.log` in production code, promoting the use of a centralized logging solution.
- Refactored error handling in the Agentforce widget and other components to align with the updated logging practices, ensuring a consistent approach to error management across the application.
- Cleaned up unused imports and optimized code structure for better maintainability.
2025-12-29 15:07:11 +09:00

196 lines
6.8 KiB
TypeScript

import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis";
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
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 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."
);
}
}
}
/**
* 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),
});
}
}
}