Skip to content

Commit 284cd51

Browse files
authored
Merge pull request #1023 from abore9769/main
fix(frontend): reliability — timeout, 5xx retry, promise rejection handling, cache invalidation
2 parents 0c170d2 + 91110b4 commit 284cd51

6 files changed

Lines changed: 450 additions & 44 deletions

File tree

frontend/src/lib/api/__tests__/cache.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,56 @@ describe('apiCache', () => {
8181
expect(CACHE_TTL.LONG).toBe(30 * 60 * 1000);
8282
});
8383
});
84+
85+
describe('tag-based invalidation (#947)', () => {
86+
it('invalidates entries matching any given tag while leaving others intact', () => {
87+
apiCache.set('url/markets/featured', [{ id: 1 }], CACHE_TTL.SHORT, ['markets']);
88+
apiCache.set('url/blockchain/markets/1', { id: 1 }, CACHE_TTL.MEDIUM, ['markets', 'blockchain']);
89+
apiCache.set('url/statistics', { total: 100 }, CACHE_TTL.MEDIUM, ['statistics']);
90+
91+
apiCache.invalidateByTags(['markets']);
92+
93+
expect(apiCache.get('url/markets/featured')).toBeNull();
94+
expect(apiCache.get('url/blockchain/markets/1')).toBeNull();
95+
// statistics has a different tag and must survive
96+
expect(apiCache.get('url/statistics')).toEqual({ total: 100 });
97+
});
98+
99+
it('invalidates entries carrying multiple tags when any matches', () => {
100+
apiCache.set('url/blockchain/stats', { txs: 50 }, CACHE_TTL.MEDIUM, ['blockchain', 'statistics']);
101+
102+
apiCache.invalidateByTags(['blockchain']);
103+
104+
expect(apiCache.get('url/blockchain/stats')).toBeNull();
105+
});
106+
107+
it('does not affect entries without tags', () => {
108+
apiCache.set('url/content', { items: [] }, CACHE_TTL.SHORT); // no tags
109+
110+
apiCache.invalidateByTags(['markets']);
111+
112+
expect(apiCache.get('url/content')).toEqual({ items: [] });
113+
});
114+
115+
it('GET returns fresh data after mutation invalidates its cache tag', () => {
116+
// Simulate a cached GET response tagged 'markets'.
117+
apiCache.set('url/markets/featured', [{ id: 1, title: 'Old' }], CACHE_TTL.SHORT, ['markets']);
118+
expect(apiCache.get('url/markets/featured')).toEqual([{ id: 1, title: 'Old' }]);
119+
120+
// Simulate a mutation that invalidates the 'markets' tag.
121+
apiCache.invalidateByTags(['markets']);
122+
123+
// Cache miss — the next GET will fetch fresh data from the server.
124+
expect(apiCache.get('url/markets/featured')).toBeNull();
125+
});
126+
127+
it('is a no-op when no entries carry the given tag', () => {
128+
apiCache.set('url/email/analytics', { sent: 10 }, CACHE_TTL.MEDIUM, ['email']);
129+
130+
// Invalidating an unrelated tag must not remove anything.
131+
apiCache.invalidateByTags(['newsletter']);
132+
133+
expect(apiCache.get('url/email/analytics')).toEqual({ sent: 10 });
134+
});
135+
});
84136
});

frontend/src/lib/api/__tests__/client.test.ts

Lines changed: 188 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { api, ApiError } from '../client';
2+
import { apiCache } from '../cache';
23

34
describe('API Client', () => {
45
const originalFetch = global.fetch;
@@ -7,6 +8,8 @@ describe('API Client', () => {
78
beforeEach(() => {
89
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001';
910
global.fetch = jest.fn();
11+
// Clear the module-level cache singleton so each test starts clean.
12+
apiCache.clear();
1013
});
1114

1215
afterEach(() => {
@@ -144,40 +147,43 @@ describe('API Client', () => {
144147
await expect(api.getBlockchainMarket(999)).rejects.toThrow('Market not found');
145148
});
146149

147-
it('should handle 500 Server Error', async () => {
148-
(global.fetch as jest.Mock).mockResolvedValueOnce({
150+
it('should handle 500 Server Error after exhausting retries', async () => {
151+
const serverError = {
149152
ok: false,
150153
status: 500,
151154
statusText: 'Internal Server Error',
152155
json: async () => ({ message: 'Database connection failed' }),
153-
});
156+
};
157+
// GET requests retry 5xx up to maxRetries (3) times — mock all attempts.
158+
(global.fetch as jest.Mock).mockResolvedValue(serverError);
154159

155160
await expect(api.getStatistics()).rejects.toThrow('Database connection failed');
156-
});
161+
expect(global.fetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
162+
}, 30_000);
157163

158164
it('should fallback to statusText when error response has no message', async () => {
159-
(global.fetch as jest.Mock).mockResolvedValueOnce({
165+
const serverError = {
160166
ok: false,
161167
status: 503,
162168
statusText: 'Service Unavailable',
163169
json: async () => ({}),
164-
});
170+
};
171+
(global.fetch as jest.Mock).mockResolvedValue(serverError);
165172

166173
await expect(api.health()).rejects.toThrow('Service Unavailable');
167-
});
174+
}, 30_000);
168175

169176
it('should fallback to HTTP status when response is not JSON', async () => {
170-
(global.fetch as jest.Mock).mockResolvedValueOnce({
177+
const serverError = {
171178
ok: false,
172179
status: 502,
173180
statusText: 'Bad Gateway',
174-
json: async () => {
175-
throw new Error('Invalid JSON');
176-
},
177-
});
181+
json: async () => { throw new Error('Invalid JSON'); },
182+
};
183+
(global.fetch as jest.Mock).mockResolvedValue(serverError);
178184

179185
await expect(api.health()).rejects.toThrow('Bad Gateway');
180-
});
186+
}, 30_000);
181187
});
182188

183189
describe('Retry behavior', () => {
@@ -347,6 +353,170 @@ describe('API Client', () => {
347353
});
348354
});
349355

356+
describe('Request timeout (#945)', () => {
357+
afterEach(() => {
358+
jest.useRealTimers();
359+
});
360+
361+
it('fires after 10 seconds and rejects with a distinct TIMEOUT_ERROR', async () => {
362+
jest.useFakeTimers();
363+
364+
(global.fetch as jest.Mock).mockImplementation((_url: string, init: RequestInit) =>
365+
new Promise((_resolve, reject) => {
366+
(init.signal as AbortSignal).addEventListener('abort', () =>
367+
reject(new DOMException('The operation was aborted.', 'AbortError'))
368+
);
369+
})
370+
);
371+
372+
// Pre-attach .catch so the rejection is never "unhandled" while timers advance.
373+
let caughtError: unknown;
374+
const settledPromise = api.health().catch(err => { caughtError = err; });
375+
376+
await jest.advanceTimersByTimeAsync(10_000);
377+
await settledPromise;
378+
379+
expect(caughtError).toMatchObject({
380+
name: 'ApiError',
381+
code: 'TIMEOUT_ERROR',
382+
message: expect.stringContaining('timed out'),
383+
});
384+
});
385+
386+
it('surfaces a timeout error distinct from a generic network error', async () => {
387+
jest.useFakeTimers();
388+
389+
(global.fetch as jest.Mock).mockImplementation((_url: string, init: RequestInit) =>
390+
new Promise((_resolve, reject) => {
391+
(init.signal as AbortSignal).addEventListener('abort', () =>
392+
reject(new DOMException('Aborted', 'AbortError'))
393+
);
394+
})
395+
);
396+
397+
let caughtError: unknown;
398+
const settledPromise = api.getStatistics().catch(err => { caughtError = err; });
399+
await jest.advanceTimersByTimeAsync(10_000);
400+
await settledPromise;
401+
402+
expect(caughtError).toBeInstanceOf(ApiError);
403+
expect((caughtError as ApiError).code).toBe('TIMEOUT_ERROR');
404+
// Must NOT be the generic network message so the UI can branch on it.
405+
expect((caughtError as ApiError).message).not.toContain('Unable to reach the server');
406+
});
407+
});
408+
409+
describe('5xx retry logic (#946)', () => {
410+
it('retries GET requests on 5xx: two 502s then 200 succeeds', async () => {
411+
const mockData = { status: 'ok' };
412+
const gatewayError = {
413+
ok: false,
414+
status: 502,
415+
statusText: 'Bad Gateway',
416+
headers: new Map(),
417+
json: async () => ({ message: 'Bad Gateway' }),
418+
};
419+
(global.fetch as jest.Mock)
420+
.mockResolvedValueOnce(gatewayError)
421+
.mockResolvedValueOnce(gatewayError)
422+
.mockResolvedValueOnce({
423+
ok: true,
424+
text: async () => JSON.stringify(mockData),
425+
});
426+
427+
const result = await api.health();
428+
expect(result).toEqual(mockData);
429+
expect(global.fetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
430+
}, 10_000);
431+
432+
it('does not retry 5xx for POST requests', async () => {
433+
(global.fetch as jest.Mock).mockResolvedValueOnce({
434+
ok: false,
435+
status: 503,
436+
statusText: 'Service Unavailable',
437+
json: async () => ({ message: 'Service Unavailable' }),
438+
});
439+
440+
await expect(
441+
api.newsletterSubscribe({ email: 'test@example.com' })
442+
).rejects.toThrow('Service Unavailable');
443+
expect(global.fetch).toHaveBeenCalledTimes(1);
444+
});
445+
446+
it('does not retry 5xx for DELETE requests', async () => {
447+
(global.fetch as jest.Mock).mockResolvedValueOnce({
448+
ok: false,
449+
status: 502,
450+
statusText: 'Bad Gateway',
451+
json: async () => ({ message: 'Bad Gateway' }),
452+
});
453+
454+
await expect(
455+
api.newsletterUnsubscribe('test@example.com')
456+
).rejects.toThrow('Bad Gateway');
457+
expect(global.fetch).toHaveBeenCalledTimes(1);
458+
});
459+
});
460+
461+
describe('Cache invalidation strategy (#947)', () => {
462+
it('invalidates only tagged resources on mutation — not the entire cache', async () => {
463+
// Prime a statistics cache entry tagged 'statistics'.
464+
(global.fetch as jest.Mock).mockResolvedValueOnce({
465+
ok: true,
466+
text: async () => JSON.stringify({ total_markets: 10 }),
467+
});
468+
await api.getStatistics();
469+
470+
// Prime a markets cache entry tagged 'markets'.
471+
(global.fetch as jest.Mock).mockResolvedValueOnce({
472+
ok: true,
473+
text: async () => JSON.stringify([{ id: 1 }]),
474+
});
475+
await api.getFeaturedMarkets();
476+
477+
// Mutate: resolveMarket invalidates 'markets', 'blockchain', 'statistics'.
478+
(global.fetch as jest.Mock).mockResolvedValueOnce({
479+
ok: true,
480+
text: async () => JSON.stringify({ invalidated_keys: 2 }),
481+
});
482+
await api.resolveMarket(1);
483+
484+
// Both statistics and markets caches must be cleared.
485+
// A fresh fetch call should now happen for getStatistics.
486+
(global.fetch as jest.Mock).mockResolvedValueOnce({
487+
ok: true,
488+
text: async () => JSON.stringify({ total_markets: 11 }),
489+
});
490+
const freshStats = await api.getStatistics();
491+
expect(freshStats).toEqual({ total_markets: 11 });
492+
});
493+
494+
it('GET returns fresh data after a mutation invalidates its cache tag', async () => {
495+
// Cache getFeaturedMarkets.
496+
(global.fetch as jest.Mock).mockResolvedValueOnce({
497+
ok: true,
498+
text: async () => JSON.stringify([{ id: 1, title: 'Old Market' }]),
499+
});
500+
const first = await api.getFeaturedMarkets();
501+
expect(first).toEqual([{ id: 1, title: 'Old Market' }]);
502+
503+
// resolveMarket invalidates the 'markets' tag.
504+
(global.fetch as jest.Mock).mockResolvedValueOnce({
505+
ok: true,
506+
text: async () => JSON.stringify({ invalidated_keys: 1 }),
507+
});
508+
await api.resolveMarket(1);
509+
510+
// Next getFeaturedMarkets must hit the network, not the cache.
511+
(global.fetch as jest.Mock).mockResolvedValueOnce({
512+
ok: true,
513+
text: async () => JSON.stringify([{ id: 1, title: 'Resolved Market' }]),
514+
});
515+
const second = await api.getFeaturedMarkets();
516+
expect(second).toEqual([{ id: 1, title: 'Resolved Market' }]);
517+
});
518+
});
519+
350520
describe('DELETE requests', () => {
351521
it('should handle DELETE requests with body', async () => {
352522
const mockResponse = { success: true };
@@ -404,12 +574,14 @@ describe('API Client', () => {
404574
});
405575

406576
it('should classify 5xx as server error', async () => {
407-
(global.fetch as jest.Mock).mockResolvedValueOnce({
577+
const serverError = {
408578
ok: false,
409579
status: 503,
410580
statusText: 'Service Unavailable',
411581
json: async () => ({ message: 'Service Unavailable' }),
412-
});
582+
};
583+
// GET retries on 5xx — provide enough responses to exhaust retries.
584+
(global.fetch as jest.Mock).mockResolvedValue(serverError);
413585

414586
try {
415587
await api.getStatistics();
@@ -419,7 +591,7 @@ describe('API Client', () => {
419591
expect((e as ApiError).isServerError).toBe(true);
420592
expect((e as ApiError).isClientError).toBe(false);
421593
}
422-
});
594+
}, 30_000);
423595

424596
it('should have name "ApiError"', async () => {
425597
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('offline'));

frontend/src/lib/api/cache.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface CacheEntry<T> {
99
timestamp: number;
1010
ttl: number;
1111
stale: boolean;
12+
tags?: readonly string[];
1213
}
1314

1415
export interface CacheResult<T> {
@@ -52,17 +53,33 @@ class ApiCache {
5253
}
5354

5455
/**
55-
* Set cache entry with TTL in milliseconds
56+
* Set cache entry with TTL in milliseconds and optional resource tags.
57+
* Tags enable targeted invalidation: use the same tag on related GET entries
58+
* so a single mutation can invalidate exactly the affected resources.
5659
*/
57-
set<T>(key: string, data: T, ttlMs: number): void {
60+
set<T>(key: string, data: T, ttlMs: number, tags?: string[]): void {
5861
this.cache.set(key, {
5962
data,
6063
timestamp: Date.now(),
6164
ttl: ttlMs,
6265
stale: false,
66+
tags,
6367
});
6468
}
6569

70+
/**
71+
* Invalidate all cache entries that carry at least one of the given tags.
72+
* Call this after a mutation to drop only the affected resource namespaces
73+
* rather than clearing the entire cache.
74+
*/
75+
invalidateByTags(tags: string[]): void {
76+
for (const [key, entry] of this.cache.entries()) {
77+
if (entry.tags?.some(tag => tags.includes(tag))) {
78+
this.cache.delete(key);
79+
}
80+
}
81+
}
82+
6683
/**
6784
* Mark a single cache entry as stale. Called when the API returns an error
6885
* for a fresh request so the UI can display the old value with a warning.

0 commit comments

Comments
 (0)