Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/constants/app.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const API_TIMEOUT_UPLOAD = 60000;
export const API_TIMEOUT_DOWNLOAD = 60000;
export const API_TIMEOUT_SEARCH = 15000;
export const API_CACHE_TTL_DEFAULT = 300000; // 5 minutes
export const API_CACHE_MAX_ENTRIES_DEFAULT = 100;
export const API_CACHE_MAX_SIZE_DEFAULT = 100;

// API URLs & Endpoints
export const DEFAULT_SOCKET_URL = 'http://localhost:3001';
Expand Down
157 changes: 155 additions & 2 deletions src/lib/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRetryDelay } from '../api';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientImpl, getRetryDelay } from '../api';
import { API_CACHE_MAX_ENTRIES_DEFAULT } from '@/constants/app.constants';

describe('getRetryDelay', () => {
afterEach(() => {
Expand All @@ -26,3 +27,155 @@ describe('getRetryDelay', () => {
expect(getRetryDelay(3, 100)).toBe(400);
});
});

describe('ApiClient LRU Response Cache', () => {
let originalFetch: typeof global.fetch;

beforeEach(() => {
originalFetch = global.fetch;
});

afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});

it('caches GET responses when useCache is true', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ message: 'hello' }),
});
global.fetch = fetchMock as any;

const client = new ApiClientImpl({ baseURL: 'https://api.example.com' });

const res1 = await client.get('/test', { useCache: true, dedupe: false });
const res2 = await client.get('/test', { useCache: true, dedupe: false });

expect(res1).toEqual({ message: 'hello' });
expect(res2).toEqual({ message: 'hello' });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(client.getCacheSize()).toBe(1);
});

it('evicts the least recently used entry when maxCacheSize is exceeded', async () => {
let callCount = 0;
const fetchMock = vi.fn().mockImplementation((url: string) => {
callCount++;
return Promise.resolve({
ok: true,
json: async () => ({ url, count: callCount }),
});
});
global.fetch = fetchMock as any;

const client = new ApiClientImpl({
baseURL: 'https://api.example.com',
maxCacheSize: 3,
});

// Populate cache up to cap (entries: 1, 2, 3)
await client.get('/item1', { useCache: true, dedupe: false });
await client.get('/item2', { useCache: true, dedupe: false });
await client.get('/item3', { useCache: true, dedupe: false });
expect(client.getCacheSize()).toBe(3);
expect(fetchMock).toHaveBeenCalledTimes(3);

// Access item4: should evict item1 (oldest / LRU)
await client.get('/item4', { useCache: true, dedupe: false });
expect(client.getCacheSize()).toBe(3);
expect(fetchMock).toHaveBeenCalledTimes(4);

// item2, item3, item4 should still be cached
await client.get('/item2', { useCache: true, dedupe: false });
await client.get('/item3', { useCache: true, dedupe: false });
await client.get('/item4', { useCache: true, dedupe: false });
expect(fetchMock).toHaveBeenCalledTimes(4); // No additional network requests

// item1 was evicted, so accessing it should trigger a fetch
await client.get('/item1', { useCache: true, dedupe: false });
expect(fetchMock).toHaveBeenCalledTimes(5);
});

it('promotes an accessed item to MRU so older unaccessed items are evicted first', async () => {
const fetchMock = vi.fn().mockImplementation((url: string) =>
Promise.resolve({
ok: true,
json: async () => ({ url }),
}),
);
global.fetch = fetchMock as any;

const client = new ApiClientImpl({
baseURL: 'https://api.example.com',
maxCacheSize: 2,
});

// Add item1 and item2 (Order: item1 [oldest], item2 [newest])
await client.get('/item1', { useCache: true, dedupe: false });
await client.get('/item2', { useCache: true, dedupe: false });

// Read item1 from cache -> refreshes item1 to MRU (Order: item2 [oldest], item1 [newest])
await client.get('/item1', { useCache: true, dedupe: false });
expect(fetchMock).toHaveBeenCalledTimes(2);

// Insert item3 -> should evict item2, NOT item1
await client.get('/item3', { useCache: true, dedupe: false });
expect(fetchMock).toHaveBeenCalledTimes(3);

// item1 should still be cached
await client.get('/item1', { useCache: true, dedupe: false });
expect(fetchMock).toHaveBeenCalledTimes(3);

// item2 was evicted -> triggers a fetch
await client.get('/item2', { useCache: true, dedupe: false });
expect(fetchMock).toHaveBeenCalledTimes(4);
});

it('supports maxCacheEntries alias in configuration', () => {
const client = new ApiClientImpl({
maxCacheEntries: 50,
});
expect(client['config'].maxCacheSize).toBe(50);
});

it('defaults to API_CACHE_MAX_ENTRIES_DEFAULT when not configured', () => {
const client = new ApiClientImpl({});
expect(client['config'].maxCacheSize).toBe(API_CACHE_MAX_ENTRIES_DEFAULT);
});

it('does not cache when maxCacheSize is 0', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ value: 123 }),
});
global.fetch = fetchMock as any;

const client = new ApiClientImpl({
baseURL: 'https://api.example.com',
maxCacheSize: 0,
});

await client.get('/test', { useCache: true, dedupe: false });
await client.get('/test', { useCache: true, dedupe: false });

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(client.getCacheSize()).toBe(0);
});

it('invalidates cache properly', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'ok' }),
});
global.fetch = fetchMock as any;

const client = new ApiClientImpl({ baseURL: 'https://api.example.com' });

await client.get('/resource', { useCache: true, dedupe: false });
expect(client.getCacheSize()).toBe(1);

client.invalidateCache();
expect(client.getCacheSize()).toBe(0);
});
});
43 changes: 40 additions & 3 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
RECONNECT_DELAY_MS,
STORAGE_KEYS,
API_CACHE_TTL_DEFAULT,
API_CACHE_MAX_ENTRIES_DEFAULT,
} from '@/constants/app.constants';
import { logContextStorage } from './logging/context';
import { tokenManager } from '@/lib/auth/tokenManager';
Expand All @@ -27,6 +28,7 @@ const DEFAULT_TIMEOUT_MS = API_TIMEOUT_DEFAULT;
const API_MAX_RETRIES = MAX_RETRIES;
const RETRY_DELAY_MS = RECONNECT_DELAY_MS;
const DEFAULT_TTL_MS = API_CACHE_TTL_DEFAULT;
const DEFAULT_MAX_CACHE_SIZE = API_CACHE_MAX_ENTRIES_DEFAULT;

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -60,6 +62,8 @@ export interface ApiClientConfig {
retryDelay?: number;
apiVersion?: string;
defaultTTL?: number;
maxCacheSize?: number;
maxCacheEntries?: number;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -104,13 +108,16 @@ class ApiClientImpl {
private errorInterceptors: ErrorInterceptor[] = [];

constructor(config: ApiClientConfig = {}) {
const maxCache = config.maxCacheSize ?? config.maxCacheEntries ?? DEFAULT_MAX_CACHE_SIZE;
this.config = {
baseURL: config.baseURL || process.env.NEXT_PUBLIC_API_URL || '',
timeout: config.timeout || DEFAULT_TIMEOUT_MS,
maxRetries: config.maxRetries || API_MAX_RETRIES,
retryDelay: config.retryDelay || RETRY_DELAY_MS,
apiVersion: config.apiVersion || DEFAULT_API_VERSION,
defaultTTL: config.defaultTTL || DEFAULT_TTL_MS,
maxCacheSize: maxCache,
maxCacheEntries: maxCache,
};
}

Expand All @@ -125,6 +132,36 @@ class ApiClientImpl {
return localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
}

private getFromCache<T>(key: string): CacheEntry<T> | undefined {
const cached = this.cache.get(key);
if (cached) {
// LRU refresh: re-insert so it becomes the most recently used entry
this.cache.delete(key);
this.cache.set(key, cached);
}
return cached;
}

private setInCache<T>(key: string, data: T): void {
if (this.config.maxCacheSize <= 0) return;

if (this.cache.has(key)) {
this.cache.delete(key);
}

while (this.cache.size >= this.config.maxCacheSize) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey === undefined) break;
this.cache.delete(oldestKey);
}

this.cache.set(key, { data, timestamp: Date.now() });
}

getCacheSize(): number {
return this.cache.size;
}

invalidateCache(url?: string) {
if (url) {
this.cache.delete(url);
Expand Down Expand Up @@ -163,7 +200,7 @@ class ApiClientImpl {

// CACHE
if (config.method === 'GET' && config.useCache && !config._bypassCacheRead) {
const cached = this.cache.get(cacheKey);
const cached = this.getFromCache<T>(cacheKey);
if (cached) {
const ttl = config.ttl ?? this.config.defaultTTL;
if (Date.now() - cached.timestamp < ttl) return cached.data;
Expand Down Expand Up @@ -228,7 +265,7 @@ class ApiClientImpl {
const data = await response.json();

if (config.method === 'GET' && config.useCache) {
this.cache.set(cacheKey, { data, timestamp: Date.now() });
this.setInCache(cacheKey, data);
}

if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(config.method || '')) {
Expand Down Expand Up @@ -327,4 +364,4 @@ class ApiClientImpl {

// Singleton
export const apiClient = new ApiClientImpl();
export type { ApiClientImpl };
export { ApiClientImpl };
Loading