From d09c14c4712fda065e1849d2914d6d602988b927 Mon Sep 17 00:00:00 2001 From: reuben148 Date: Sun, 30 Aug 2026 18:46:08 +0100 Subject: [PATCH] feat(api,hooks): add client request caching, retry backoff, and mount/unmount hooks - Cache ApiClient GET responses with TTL, mutation invalidation, and a bypass option (#297) - Retry GET requests on 5xx and network errors with exponential backoff (1s/2s/4s, max 3) and stop retrying mutations (#296) - Add useMount and useUnmount hooks (#294, #295) Closes #294 Closes #295 Closes #296 Closes #297 --- src/lib/api/client.test.ts | 4 +- src/lib/api/client.ts | 102 +++++++++++++++++++++++++++++------ src/lib/hooks/use-mount.ts | 18 +++++++ src/lib/hooks/use-unmount.ts | 20 +++++++ 4 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 src/lib/hooks/use-mount.ts create mode 100644 src/lib/hooks/use-unmount.ts diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts index 6851b58..4af496d 100644 --- a/src/lib/api/client.test.ts +++ b/src/lib/api/client.test.ts @@ -75,7 +75,7 @@ describe("apiClient abort support", () => { abortableFetch(); const controller = new AbortController(); - // get() is configured with 2 retries; an external abort must never retry. + // get() is configured with 3 retries; an external abort must never retry. const request = apiClient.get("/courses", undefined, controller.signal); controller.abort(); @@ -127,6 +127,6 @@ describe("apiClient abort support", () => { await vi.runAllTimersAsync(); await assertion; - expect(fetchMock).toHaveBeenCalledTimes(3); // initial + 2 retries + expect(fetchMock).toHaveBeenCalledTimes(4); // initial + 3 retries }); }); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index a2ecd53..f815cde 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -5,7 +5,18 @@ import { useErrorStore } from "@/store/error-store"; const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; const REQUEST_TIMEOUT_MS = 15000; -const RETRY_BASE_DELAY_MS = 300; +const RETRY_BASE_DELAY_MS = 1000; +const MAX_RETRIES = 3; + +const CACHE_TTL_MS = 60_000; +const CACHE_MAX_SIZE = 100; + +type CacheEntry = { + value: unknown; + expiresAt: number; +}; + +const responseCache = new Map(); function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -84,10 +95,11 @@ class ApiClient { } /** - * Runs fetch with a request timeout and retries on transient network - * failures (connection drop, DNS failure, timeout). Retries are skipped - * for POST since a failed connection doesn't guarantee the server never - * received the request, and POST is generally not idempotent. + * Runs fetch with a request timeout and retries on transient failures with + * exponential backoff (1s, 2s, 4s) up to `retries` attempts. GET retries on + * 5xx responses and network errors; 4xx responses are never retried. + * Mutations pass 0 retries since a failed connection doesn't guarantee the + * server never received the request, and writes are generally not idempotent. * * An optional external signal (typically an AbortController created by a * hook's cleanup) cancels the in-flight request immediately. External @@ -115,7 +127,15 @@ class ApiClient { ...init, signal: controller.signal, }); - return response; + + if (response.status < 500 || attempt >= retries) { + return response; + } + + console.warn( + `API request to ${url} returned ${response.status}; retrying (${attempt + 1}/${retries})` + ); + await delay(RETRY_BASE_DELAY_MS * 2 ** attempt); } catch (error) { if (signal?.aborted) { throw createAbortError(); @@ -136,6 +156,9 @@ class ApiClient { "NETWORK_ERROR" ); } + console.warn( + `API request to ${url} failed; retrying (${attempt + 1}/${retries})` + ); await delay(RETRY_BASE_DELAY_MS * 2 ** attempt); } finally { clearTimeout(timeoutId); @@ -149,19 +172,62 @@ class ApiClient { useErrorStore.getState().setError(error, isTransient); } + private cacheKey(url: string, jwt?: string): string { + return jwt ? `${url}|${jwt}` : url; + } + + private getCached(key: string): T | undefined { + const entry = responseCache.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + responseCache.delete(key); + return undefined; + } + return entry.value as T; + } + + private setCached(key: string, value: T): void { + if (responseCache.size >= CACHE_MAX_SIZE) { + const oldest = responseCache.keys().next().value; + if (oldest !== undefined) { + responseCache.delete(oldest); + } + } + responseCache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); + } + + private invalidateCache(): void { + responseCache.clear(); + } + async get( path: string, jwt?: string, - signal?: AbortSignal + signal?: AbortSignal, + options?: { bypassCache?: boolean } ): Promise> { + const url = `${this.baseUrl}${path}`; + const key = this.cacheKey(url, jwt); + + if (!options?.bypassCache) { + const cached = this.getCached>(key); + if (cached !== undefined) { + return cached; + } + } + try { const response = await this.fetchWithRetry( - `${this.baseUrl}${path}`, + url, { method: "GET", headers: this.getHeaders(jwt) }, - 2, + MAX_RETRIES, signal ); - return this.handleResponse>(response); + const data = await this.handleResponse>(response); + if (!options?.bypassCache) { + this.setCached(key, data); + } + return data; } catch (error) { if (error instanceof ApiError) { this.handleApiError(error); @@ -187,7 +253,9 @@ class ApiClient { 0, signal ); - return this.handleResponse>(response); + const data = await this.handleResponse>(response); + this.invalidateCache(); + return data; } catch (error) { if (error instanceof ApiError) { this.handleApiError(error); @@ -210,10 +278,12 @@ class ApiClient { headers: this.getHeaders(jwt), body: JSON.stringify(body), }, - 2, + 0, signal ); - return this.handleResponse>(response); + const data = await this.handleResponse>(response); + this.invalidateCache(); + return data; } catch (error) { if (error instanceof ApiError) { this.handleApiError(error); @@ -231,10 +301,12 @@ class ApiClient { const response = await this.fetchWithRetry( `${this.baseUrl}${path}`, { method: "DELETE", headers: this.getHeaders(jwt) }, - 2, + 0, signal ); - return this.handleResponse>(response); + const data = await this.handleResponse>(response); + this.invalidateCache(); + return data; } catch (error) { if (error instanceof ApiError) { this.handleApiError(error); diff --git a/src/lib/hooks/use-mount.ts b/src/lib/hooks/use-mount.ts new file mode 100644 index 0000000..7d43ada --- /dev/null +++ b/src/lib/hooks/use-mount.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Run a callback only when the component mounts (#294). + * + * The latest callback is invoked through a ref, so passing an inline function + * does not re-run the effect across re-renders. Async callbacks are supported. + */ +export function useMount(callback: () => void): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + callbackRef.current(); + }, []); +} \ No newline at end of file diff --git a/src/lib/hooks/use-unmount.ts b/src/lib/hooks/use-unmount.ts new file mode 100644 index 0000000..8667a10 --- /dev/null +++ b/src/lib/hooks/use-unmount.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Run a callback when the component unmounts (#295). + * + * The latest callback is invoked through a ref, so re-renders don't re-run the + * effect. Async callbacks are supported. + */ +export function useUnmount(callback: () => void): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + return () => { + callbackRef.current(); + }; + }, []); +} \ No newline at end of file