feat: add retry functionality to ErrorMessage component and implement retry logic in ClientLibraryPage and ClientSeriesPage for improved error handling
This commit is contained in:
@@ -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 (
|
||||
<div className="container py-8 space-y-8">
|
||||
@@ -132,7 +165,7 @@ export function ClientLibraryPage({
|
||||
</h1>
|
||||
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
|
||||
</div>
|
||||
<ErrorMessage errorCode={error} />
|
||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +173,7 @@ export function ClientLibraryPage({
|
||||
if (!library || !series) {
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<ErrorMessage errorCode="SERIES_FETCH_ERROR" />
|
||||
<ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="container py-8 space-y-8">
|
||||
@@ -117,7 +146,7 @@ export function ClientSeriesPage({
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<h1 className="text-3xl font-bold">Série</h1>
|
||||
<ErrorMessage errorCode={error} />
|
||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,7 +155,7 @@ export function ClientSeriesPage({
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<p>{message}</p>
|
||||
{onRetry && (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +67,18 @@ export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMes
|
||||
{t("errors.GENERIC_ERROR")}
|
||||
</h3>
|
||||
<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>
|
||||
|
||||
@@ -412,6 +412,9 @@
|
||||
"scrollRight": "Scroll right",
|
||||
"volume": "Volume {number}"
|
||||
},
|
||||
"common": {
|
||||
"retry": "Retry"
|
||||
},
|
||||
"debug": {
|
||||
"title": "DEBUG",
|
||||
"entries": "entry",
|
||||
|
||||
@@ -414,6 +414,9 @@
|
||||
"scrollRight": "Défiler vers la droite",
|
||||
"volume": "Tome {number}"
|
||||
},
|
||||
"common": {
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"debug": {
|
||||
"title": "DEBUG",
|
||||
"entries": "entrée",
|
||||
|
||||
@@ -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<T>(
|
||||
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<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 {
|
||||
this.delete(key);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user