veza/apps/web/src/services/requestDeduplication.ts

239 lines
6.3 KiB
TypeScript

/**
* 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<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}`;
// 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<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++;
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,
);
}