From 77742bbec29dd29adf9f0af30d5ff875fb75e386 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 17 Oct 2025 10:21:35 +0200 Subject: [PATCH] feat: add retry functionality to ErrorMessage component and implement retry logic in ClientLibraryPage and ClientSeriesPage for improved error handling --- .../[libraryId]/ClientLibraryPage.tsx | 37 +++++++- .../series/[seriesId]/ClientSeriesPage.tsx | 33 ++++++- src/components/ui/ErrorMessage.tsx | 35 +++++++- src/i18n/messages/en/common.json | 3 + src/i18n/messages/fr/common.json | 3 + src/lib/services/server-cache.service.ts | 87 +++++++++++++++++-- 6 files changed, 186 insertions(+), 12 deletions(-) diff --git a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx index 5320713..3496770 100644 --- a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx +++ b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx @@ -107,6 +107,39 @@ export function ClientLibraryPage({ } }; + const handleRetry = async () => { + setError(null); + setLoading(true); + + try { + const params = new URLSearchParams({ + page: String(currentPage - 1), + size: String(effectivePageSize), + unread: String(unreadOnly), + }); + + if (search) { + params.append("search", search); + } + + const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR"); + } + + const data = await response.json(); + setLibrary(data.library); + setSeries(data.series); + } catch (err) { + console.error("Error fetching library series:", err); + setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR"); + } finally { + setLoading(false); + } + }; + if (loading) { return (
@@ -132,7 +165,7 @@ export function ClientLibraryPage({
- + ); } @@ -140,7 +173,7 @@ export function ClientLibraryPage({ if (!library || !series) { return (
- +
); } diff --git a/src/app/series/[seriesId]/ClientSeriesPage.tsx b/src/app/series/[seriesId]/ClientSeriesPage.tsx index f10d6e7..f512979 100644 --- a/src/app/series/[seriesId]/ClientSeriesPage.tsx +++ b/src/app/series/[seriesId]/ClientSeriesPage.tsx @@ -97,6 +97,35 @@ export function ClientSeriesPage({ } }; + const handleRetry = async () => { + setError(null); + setLoading(true); + + try { + const params = new URLSearchParams({ + page: String(currentPage - 1), + size: String(effectivePageSize), + unread: String(unreadOnly), + }); + + const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR); + } + + const data = await response.json(); + setSeries(data.series); + setBooks(data.books); + } catch (err) { + console.error("Error fetching series books:", err); + setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR); + } finally { + setLoading(false); + } + }; + if (loading) { return (
@@ -117,7 +146,7 @@ export function ClientSeriesPage({ return (

Série

- +
); } @@ -126,7 +155,7 @@ export function ClientSeriesPage({ return (

Série

- +
); } diff --git a/src/components/ui/ErrorMessage.tsx b/src/components/ui/ErrorMessage.tsx index 72d3322..bf58f12 100644 --- a/src/components/ui/ErrorMessage.tsx +++ b/src/components/ui/ErrorMessage.tsx @@ -1,15 +1,24 @@ "use client"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, RefreshCw } from "lucide-react"; import { useTranslate } from "@/hooks/useTranslate"; +import { Button } from "@/components/ui/button"; interface ErrorMessageProps { errorCode: string; error?: Error; variant?: "default" | "form"; + onRetry?: () => void; + retryLabel?: string; } -export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMessageProps) => { +export const ErrorMessage = ({ + errorCode, + error, + variant = "default", + onRetry, + retryLabel, +}: ErrorMessageProps) => { const { t } = useTranslate(); const message = t(`errors.${errorCode}`); @@ -26,6 +35,16 @@ export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMes >

{message}

+ {onRetry && ( + + )}
); } @@ -48,6 +67,18 @@ export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMes {t("errors.GENERIC_ERROR")}

{message}

+ + {onRetry && ( + + )} diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 57ab1ef..1c7d378 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -412,6 +412,9 @@ "scrollRight": "Scroll right", "volume": "Volume {number}" }, + "common": { + "retry": "Retry" + }, "debug": { "title": "DEBUG", "entries": "entry", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 258cadc..6fa7adf 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -414,6 +414,9 @@ "scrollRight": "Défiler vers la droite", "volume": "Tome {number}" }, + "common": { + "retry": "Réessayer" + }, "debug": { "title": "DEBUG", "entries": "entrée", diff --git a/src/lib/services/server-cache.service.ts b/src/lib/services/server-cache.service.ts index 2f2bb43..fae6fbb 100644 --- a/src/lib/services/server-cache.service.ts +++ b/src/lib/services/server-cache.service.ts @@ -289,6 +289,40 @@ class ServerCacheService { } } + /** + * Récupère des données du cache même si elles sont expirées (stale) + * Retourne { data, isStale } ou null si pas de cache + */ + private getStale(key: string): { data: any; isStale: boolean } | null { + if (this.config.mode === "memory") { + const cached = this.memoryCache.get(key); + if (!cached) return null; + + return { + data: cached.data, + isStale: cached.expiry <= Date.now(), + }; + } + + const filePath = this.getCacheFilePath(key); + if (!fs.existsSync(filePath)) { + return null; + } + + try { + const content = fs.readFileSync(filePath, "utf-8"); + const cached = JSON.parse(content); + + return { + data: cached.data, + isStale: cached.expiry <= Date.now(), + }; + } catch (error) { + console.error(`Error reading cache file ${filePath}:`, error); + return null; + } + } + /** * Supprime une entrée du cache */ @@ -383,6 +417,10 @@ class ServerCacheService { /** * Récupère des données du cache ou exécute la fonction si nécessaire + * Stratégie stale-while-revalidate: + * - Cache valide → retourne immédiatement + * - Cache expiré → retourne le cache expiré ET revalide en background + * - Pas de cache → fetch normalement */ async getOrSet( key: string, @@ -396,24 +434,33 @@ class ServerCacheService { } const cacheKey = `${user.id}-${key}`; - const cached = this.get(cacheKey); - if (cached !== null) { + const cachedResult = this.getStale(cacheKey); + + if (cachedResult !== null) { + const { data, isStale } = cachedResult; const endTime = performance.now(); - // Log la requête avec l'indication du cache (URL plus claire) + // Log la requête avec l'indication du cache await DebugService.logRequest({ - url: `[CACHE] ${key}`, + url: `[CACHE${isStale ? '-STALE' : ''}] ${key}`, startTime, endTime, fromCache: true, cacheType: type, }); - return cached as T; + + // Si le cache est expiré, revalider en background sans bloquer la réponse + if (isStale) { + // Fire and forget - revalidate en background + this.revalidateInBackground(cacheKey, fetcher, type, key); + } + + return data as T; } + // Pas de cache du tout, fetch normalement try { const data = await fetcher(); - this.set(cacheKey, data, type); return data; } catch (error) { @@ -421,6 +468,34 @@ class ServerCacheService { } } + /** + * Revalide le cache en background + */ + private async revalidateInBackground( + cacheKey: string, + fetcher: () => Promise, + type: keyof typeof ServerCacheService.DEFAULT_TTL, + debugKey: string + ): Promise { + try { + const startTime = performance.now(); + const data = await fetcher(); + this.set(cacheKey, data, type); + + const endTime = performance.now(); + await DebugService.logRequest({ + url: `[REVALIDATE] ${debugKey}`, + startTime, + endTime, + fromCache: false, + cacheType: type, + }); + } catch (error) { + console.error(`Background revalidation failed for ${debugKey}:`, error); + // Ne pas relancer l'erreur car c'est en background + } + } + invalidate(key: string): void { this.delete(key); }