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) {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user