/** * Request Deduplication Service * FE-API-016: Deduplicate identical concurrent requests * * Prevents duplicate API calls by sharing the same promise for identical concurrent requests */ import { AxiosRequestConfig } from 'axios'; import { logger } from '@/utils/logger'; /** * Options for request deduplication */ export interface DeduplicationOptions { /** Whether to enable deduplication for this request (default: true) */ enabled?: boolean; /** Maximum time to keep a request in cache after completion (ms) */ cacheTime?: number; } /** * Cached request with its promise */ interface CachedRequest { promise: Promise; timestamp: number; resolveCount: number; } /** * Request Deduplication Service * Manages concurrent identical requests by sharing promises */ class RequestDeduplicationService { private cache: Map = new Map(); private defaultCacheTime = 1000; // 1 second default cache time /** * Generate a unique key for a request * Considers method, URL, params, and body */ private generateRequestKey(config: AxiosRequestConfig): string { const method = (config.method || 'GET').toUpperCase(); const url = config.url || ''; const baseURL = config.baseURL || ''; const fullUrl = url.startsWith('http') ? url : `${baseURL}${url}`; // Sort params for consistent key generation const params = config.params ? Object.keys(config.params) .sort() .map((key) => `${key}=${JSON.stringify(config.params![key])}`) .join('&') : ''; // Serialize body for consistent key generation let bodyKey = ''; if (config.data) { if (config.data instanceof FormData) { // For FormData, we can't easily serialize, so use a hash or skip // For now, we'll include a flag that it's FormData bodyKey = '[FormData]'; } else { try { bodyKey = JSON.stringify(config.data); } catch { bodyKey = String(config.data); } } } return `${method}:${fullUrl}${params ? `?${params}` : ''}${bodyKey ? `|${bodyKey}` : ''}`; } /** * Check if deduplication should be enabled for a request * Some requests (like POST with different data) should not be deduplicated */ private shouldDeduplicate(config: AxiosRequestConfig): boolean { const method = (config.method || 'GET').toUpperCase(); // Always deduplicate GET, HEAD, OPTIONS requests if (['GET', 'HEAD', 'OPTIONS'].includes(method)) { return true; } // For mutations, only deduplicate if explicitly enabled // This prevents accidental deduplication of different POST requests const deduplicationEnabled = (config as any)?._enableDeduplication !== false; // Don't deduplicate if explicitly disabled if ((config as any)?._disableDeduplication === true) { return false; } return deduplicationEnabled; } /** * Get or create a request promise * If an identical request is already in progress, returns the same promise */ async getOrCreateRequest( config: AxiosRequestConfig, requestFn: () => Promise, options: DeduplicationOptions = {}, ): Promise { const { enabled = true, cacheTime = this.defaultCacheTime } = options; // Check if deduplication is enabled if (!enabled || !this.shouldDeduplicate(config)) { return requestFn(); } const key = this.generateRequestKey(config); const cached = this.cache.get(key); // If request is already in progress, return the same promise if (cached) { cached.resolveCount++; logger.debug( `[RequestDeduplication] Reusing request: ${config.method?.toUpperCase()} ${config.url}`, { key, resolveCount: cached.resolveCount, }, ); return cached.promise; } // Create new request promise const promise = requestFn() .then((result) => { // Keep in cache for a short time after completion // This helps with rapid successive calls setTimeout(() => { const cached = this.cache.get(key); if (cached && cached.promise === promise) { this.cache.delete(key); logger.debug(`[RequestDeduplication] Removed from cache: ${key}`); } }, cacheTime); return result; }) .catch((error) => { // Remove from cache immediately on error const cached = this.cache.get(key); if (cached && cached.promise === promise) { this.cache.delete(key); logger.debug( `[RequestDeduplication] Removed from cache (error): ${key}`, ); } throw error; }); // Store in cache this.cache.set(key, { promise, timestamp: Date.now(), resolveCount: 1, }); logger.debug( `[RequestDeduplication] New request: ${config.method?.toUpperCase()} ${config.url}`, { key, cacheSize: this.cache.size, }, ); return promise; } /** * Clear the cache */ clearCache(): void { const size = this.cache.size; this.cache.clear(); logger.info(`[RequestDeduplication] Cache cleared (${size} entries)`); } /** * Get cache statistics */ getCacheStats(): { size: number; entries: Array<{ key: string; resolveCount: number; age: number }>; } { const entries = Array.from(this.cache.entries()).map(([key, cached]) => ({ key, resolveCount: cached.resolveCount, age: Date.now() - cached.timestamp, })); return { size: this.cache.size, entries, }; } /** * Clean up old cache entries * Removes entries older than the specified age */ cleanup(maxAge: number = 60000): void { const now = Date.now(); let removed = 0; for (const [key, cached] of this.cache.entries()) { if (now - cached.timestamp > maxAge) { this.cache.delete(key); removed++; } } if (removed > 0) { logger.debug( `[RequestDeduplication] Cleaned up ${removed} old cache entries`, ); } } } // Singleton instance export const requestDeduplication = new RequestDeduplicationService(); // Periodic cleanup (every 5 minutes) if (typeof window !== 'undefined') { setInterval( () => { requestDeduplication.cleanup(60000); // Remove entries older than 1 minute }, 5 * 60 * 1000, ); }