2025-12-25 12:26:27 +00:00
|
|
|
/**
|
|
|
|
|
* Request Deduplication Service
|
|
|
|
|
* FE-API-016: Deduplicate identical concurrent requests
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2025-12-25 12:26:27 +00:00
|
|
|
* 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<any>;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
resolveCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Request Deduplication Service
|
|
|
|
|
* Manages concurrent identical requests by sharing promises
|
|
|
|
|
*/
|
|
|
|
|
class RequestDeduplicationService {
|
|
|
|
|
private cache: Map<string, CachedRequest> = 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}`;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
// Sort params for consistent key generation
|
|
|
|
|
const params = config.params
|
|
|
|
|
? Object.keys(config.params)
|
|
|
|
|
.sort()
|
|
|
|
|
.map((key) => `${key}=${JSON.stringify(config.params![key])}`)
|
|
|
|
|
.join('&')
|
|
|
|
|
: '';
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
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();
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
// Always deduplicate GET, HEAD, OPTIONS requests
|
|
|
|
|
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
// For mutations, only deduplicate if explicitly enabled
|
|
|
|
|
// This prevents accidental deduplication of different POST requests
|
2026-01-13 18:47:57 +00:00
|
|
|
const deduplicationEnabled =
|
|
|
|
|
(config as any)?._enableDeduplication !== false;
|
|
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
// Don't deduplicate if explicitly disabled
|
|
|
|
|
if ((config as any)?._disableDeduplication === true) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
return deduplicationEnabled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get or create a request promise
|
|
|
|
|
* If an identical request is already in progress, returns the same promise
|
|
|
|
|
*/
|
|
|
|
|
async getOrCreateRequest<T>(
|
|
|
|
|
config: AxiosRequestConfig,
|
|
|
|
|
requestFn: () => Promise<T>,
|
|
|
|
|
options: DeduplicationOptions = {},
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
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++;
|
2026-01-13 18:47:57 +00:00
|
|
|
logger.debug(
|
|
|
|
|
`[RequestDeduplication] Reusing request: ${config.method?.toUpperCase()} ${config.url}`,
|
|
|
|
|
{
|
|
|
|
|
key,
|
|
|
|
|
resolveCount: cached.resolveCount,
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-12-25 12:26:27 +00:00
|
|
|
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);
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2025-12-25 12:26:27 +00:00
|
|
|
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);
|
2026-01-13 18:47:57 +00:00
|
|
|
logger.debug(
|
|
|
|
|
`[RequestDeduplication] Removed from cache (error): ${key}`,
|
|
|
|
|
);
|
2025-12-25 12:26:27 +00:00
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Store in cache
|
|
|
|
|
this.cache.set(key, {
|
|
|
|
|
promise,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
resolveCount: 1,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
logger.debug(
|
|
|
|
|
`[RequestDeduplication] New request: ${config.method?.toUpperCase()} ${config.url}`,
|
|
|
|
|
{
|
|
|
|
|
key,
|
|
|
|
|
cacheSize: this.cache.size,
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-12-25 12:26:27 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-01-13 18:47:57 +00:00
|
|
|
getCacheStats(): {
|
|
|
|
|
size: number;
|
|
|
|
|
entries: Array<{ key: string; resolveCount: number; age: number }>;
|
|
|
|
|
} {
|
2025-12-25 12:26:27 +00:00
|
|
|
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) {
|
2026-01-13 18:47:57 +00:00
|
|
|
logger.debug(
|
|
|
|
|
`[RequestDeduplication] Cleaned up ${removed} old cache entries`,
|
|
|
|
|
);
|
2025-12-25 12:26:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Singleton instance
|
|
|
|
|
export const requestDeduplication = new RequestDeduplicationService();
|
|
|
|
|
|
|
|
|
|
// Periodic cleanup (every 5 minutes)
|
|
|
|
|
if (typeof window !== 'undefined') {
|
2026-01-13 18:47:57 +00:00
|
|
|
setInterval(
|
|
|
|
|
() => {
|
|
|
|
|
requestDeduplication.cleanup(60000); // Remove entries older than 1 minute
|
|
|
|
|
},
|
|
|
|
|
5 * 60 * 1000,
|
|
|
|
|
);
|
2025-12-25 12:26:27 +00:00
|
|
|
}
|