feat: add retry functionality to ErrorMessage component and implement retry logic in ClientLibraryPage and ClientSeriesPage for improved error handling

This commit is contained in:
Julien Froidefond
2025-10-17 10:21:35 +02:00
parent 946b495ce2
commit 77742bbec2
6 changed files with 186 additions and 12 deletions

View File

@@ -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) { if (loading) {
return ( return (
<div className="container py-8 space-y-8"> <div className="container py-8 space-y-8">
@@ -132,7 +165,7 @@ export function ClientLibraryPage({
</h1> </h1>
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} /> <RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
</div> </div>
<ErrorMessage errorCode={error} /> <ErrorMessage errorCode={error} onRetry={handleRetry} />
</div> </div>
); );
} }
@@ -140,7 +173,7 @@ export function ClientLibraryPage({
if (!library || !series) { if (!library || !series) {
return ( return (
<div className="container py-8 space-y-8"> <div className="container py-8 space-y-8">
<ErrorMessage errorCode="SERIES_FETCH_ERROR" /> <ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
</div> </div>
); );
} }

View File

@@ -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) { if (loading) {
return ( return (
<div className="container py-8 space-y-8"> <div className="container py-8 space-y-8">
@@ -117,7 +146,7 @@ export function ClientSeriesPage({
return ( return (
<div className="container py-8 space-y-8"> <div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Série</h1> <h1 className="text-3xl font-bold">Série</h1>
<ErrorMessage errorCode={error} /> <ErrorMessage errorCode={error} onRetry={handleRetry} />
</div> </div>
); );
} }
@@ -126,7 +155,7 @@ export function ClientSeriesPage({
return ( return (
<div className="container py-8 space-y-8"> <div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Série</h1> <h1 className="text-3xl font-bold">Série</h1>
<ErrorMessage errorCode={ERROR_CODES.SERIES.FETCH_ERROR} /> <ErrorMessage errorCode={ERROR_CODES.SERIES.FETCH_ERROR} onRetry={handleRetry} />
</div> </div>
); );
} }

View File

@@ -1,15 +1,24 @@
"use client"; "use client";
import { AlertCircle } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { Button } from "@/components/ui/button";
interface ErrorMessageProps { interface ErrorMessageProps {
errorCode: string; errorCode: string;
error?: Error; error?: Error;
variant?: "default" | "form"; 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 { t } = useTranslate();
const message = t(`errors.${errorCode}`); const message = t(`errors.${errorCode}`);
@@ -26,6 +35,16 @@ export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMes
> >
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<p>{message}</p> <p>{message}</p>
{onRetry && (
<Button
onClick={onRetry}
variant="ghost"
size="sm"
className="ml-auto"
>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div> </div>
); );
} }
@@ -48,6 +67,18 @@ export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMes
{t("errors.GENERIC_ERROR")} {t("errors.GENERIC_ERROR")}
</h3> </h3>
<p className="text-sm text-destructive/90 dark:text-red-300/90">{message}</p> <p className="text-sm text-destructive/90 dark:text-red-300/90">{message}</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
size="sm"
className="mt-4 border-destructive/30 hover:bg-destructive/10"
>
<RefreshCw className="mr-2 h-4 w-4" />
{retryLabel || t("common.retry")}
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -412,6 +412,9 @@
"scrollRight": "Scroll right", "scrollRight": "Scroll right",
"volume": "Volume {number}" "volume": "Volume {number}"
}, },
"common": {
"retry": "Retry"
},
"debug": { "debug": {
"title": "DEBUG", "title": "DEBUG",
"entries": "entry", "entries": "entry",

View File

@@ -414,6 +414,9 @@
"scrollRight": "Défiler vers la droite", "scrollRight": "Défiler vers la droite",
"volume": "Tome {number}" "volume": "Tome {number}"
}, },
"common": {
"retry": "Réessayer"
},
"debug": { "debug": {
"title": "DEBUG", "title": "DEBUG",
"entries": "entrée", "entries": "entrée",

View File

@@ -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 * 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 * 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<T>( async getOrSet<T>(
key: string, key: string,
@@ -396,24 +434,33 @@ class ServerCacheService {
} }
const cacheKey = `${user.id}-${key}`; const cacheKey = `${user.id}-${key}`;
const cached = this.get(cacheKey); const cachedResult = this.getStale(cacheKey);
if (cached !== null) {
if (cachedResult !== null) {
const { data, isStale } = cachedResult;
const endTime = performance.now(); 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({ await DebugService.logRequest({
url: `[CACHE] ${key}`, url: `[CACHE${isStale ? '-STALE' : ''}] ${key}`,
startTime, startTime,
endTime, endTime,
fromCache: true, fromCache: true,
cacheType: type, 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 { try {
const data = await fetcher(); const data = await fetcher();
this.set(cacheKey, data, type); this.set(cacheKey, data, type);
return data; return data;
} catch (error) { } catch (error) {
@@ -421,6 +468,34 @@ class ServerCacheService {
} }
} }
/**
* Revalide le cache en background
*/
private async revalidateInBackground<T>(
cacheKey: string,
fetcher: () => Promise<T>,
type: keyof typeof ServerCacheService.DEFAULT_TTL,
debugKey: string
): Promise<void> {
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 { invalidate(key: string): void {
this.delete(key); this.delete(key);
} }