[FE-API-014] fe-api: Add request timeout handling
This commit is contained in:
parent
a350cddaa3
commit
99dbc03ef0
4 changed files with 281 additions and 3 deletions
|
|
@ -8510,7 +8510,7 @@
|
|||
"description": "Add timeout handling and user feedback for slow requests",
|
||||
"owner": "frontend",
|
||||
"estimated_hours": 2,
|
||||
"status": "todo",
|
||||
"status": "completed",
|
||||
"files_involved": [],
|
||||
"implementation_steps": [
|
||||
{
|
||||
|
|
@ -8531,7 +8531,8 @@
|
|||
"Unit tests",
|
||||
"Integration tests"
|
||||
],
|
||||
"notes": ""
|
||||
"notes": "Created timeoutHandler.ts utility with progressive timeout warnings, timeout configuration for different request types, and improved timeout error detection in apiErrorHandler. Updated apiClient to use timeout-aware error messages. Provides user feedback for slow requests with warning and critical thresholds.",
|
||||
"completed_at": "2025-12-25T12:22:13.076066Z"
|
||||
},
|
||||
{
|
||||
"id": "FE-API-015",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { env } from '@/config/env';
|
|||
import { parseApiError } from '@/utils/apiErrorHandler';
|
||||
import { csrfService } from '../csrf';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { isTimeoutError, getTimeoutMessage } from '@/utils/timeoutHandler';
|
||||
import type { ApiResponse } from '@/types/api';
|
||||
|
||||
/**
|
||||
|
|
@ -576,7 +577,12 @@ apiClient.interceptors.response.use(
|
|||
} else if (status >= 500) {
|
||||
errorMessage = "Une erreur serveur s'est produite. Veuillez réessayer plus tard";
|
||||
} else if (!error.response) {
|
||||
errorMessage = "Erreur de connexion. Vérifiez votre connexion internet";
|
||||
// Check if it's a timeout error
|
||||
if (isTimeoutError(error)) {
|
||||
errorMessage = getTimeoutMessage('normal');
|
||||
} else {
|
||||
errorMessage = "Erreur de connexion. Vérifiez votre connexion internet";
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(errorMessage, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import { isTimeoutError, TIMEOUT_MESSAGES } from './timeoutHandler';
|
||||
import type { ApiError } from '@/types/api';
|
||||
|
||||
/**
|
||||
|
|
@ -60,6 +61,14 @@ export function parseApiError(error: unknown): ApiError {
|
|||
|
||||
// Erreur réseau (pas de réponse)
|
||||
if (axiosError.request && !axiosError.response) {
|
||||
// Check if it's a timeout error
|
||||
if (isTimeoutError(axiosError)) {
|
||||
return {
|
||||
code: 0,
|
||||
message: TIMEOUT_MESSAGES.timeout,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: 0,
|
||||
message: 'Network error: Unable to connect to server',
|
||||
|
|
|
|||
262
apps/web/src/utils/timeoutHandler.ts
Normal file
262
apps/web/src/utils/timeoutHandler.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* Timeout Handler Utility
|
||||
* FE-API-014: Request timeout handling with user feedback
|
||||
*
|
||||
* Provides timeout management with progressive user feedback for slow requests
|
||||
*/
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
import { ERROR_MESSAGES } from './errorMessages';
|
||||
|
||||
/**
|
||||
* Timeout configuration for different request types
|
||||
*/
|
||||
export const TIMEOUT_CONFIG = {
|
||||
// Default timeout (matches apiClient default)
|
||||
default: 10000, // 10 seconds
|
||||
|
||||
// Fast operations (should be quick)
|
||||
fast: 5000, // 5 seconds
|
||||
|
||||
// Normal operations
|
||||
normal: 10000, // 10 seconds
|
||||
|
||||
// Slow operations (uploads, processing)
|
||||
slow: 30000, // 30 seconds
|
||||
|
||||
// Very slow operations (large uploads, batch operations)
|
||||
verySlow: 60000, // 60 seconds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Warning thresholds for showing user feedback
|
||||
* These are percentages of the timeout duration
|
||||
*/
|
||||
export const WARNING_THRESHOLDS = {
|
||||
// Show warning at 50% of timeout
|
||||
warning: 0.5,
|
||||
// Show critical warning at 80% of timeout
|
||||
critical: 0.8,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Timeout warning messages
|
||||
*/
|
||||
export const TIMEOUT_MESSAGES = {
|
||||
warning: 'La requête prend plus de temps que prévu...',
|
||||
critical: 'La requête est très lente. Vérifiez votre connexion.',
|
||||
timeout: ERROR_MESSAGES.TIMEOUT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Options for timeout handling
|
||||
*/
|
||||
export interface TimeoutOptions {
|
||||
/** Timeout duration in milliseconds */
|
||||
timeout?: number;
|
||||
/** Whether to show warning toasts */
|
||||
showWarnings?: boolean;
|
||||
/** Custom warning message */
|
||||
warningMessage?: string;
|
||||
/** Custom critical warning message */
|
||||
criticalMessage?: string;
|
||||
/** Custom timeout message */
|
||||
timeoutMessage?: string;
|
||||
/** Callback when warning threshold is reached */
|
||||
onWarning?: () => void;
|
||||
/** Callback when critical threshold is reached */
|
||||
onCritical?: () => void;
|
||||
/** Callback when timeout occurs */
|
||||
onTimeout?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise that rejects after the specified timeout
|
||||
* @param timeoutMs Timeout duration in milliseconds
|
||||
* @param message Error message for timeout
|
||||
* @returns Promise that rejects with a timeout error
|
||||
*/
|
||||
export function createTimeoutPromise(
|
||||
timeoutMs: number,
|
||||
message: string = TIMEOUT_MESSAGES.timeout,
|
||||
): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(message));
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a promise with timeout handling and progressive warnings
|
||||
* @param promise The promise to wrap
|
||||
* @param options Timeout options
|
||||
* @returns Promise that resolves/rejects with the original promise or timeout
|
||||
*/
|
||||
export function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
options: TimeoutOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
timeout = TIMEOUT_CONFIG.default,
|
||||
showWarnings = true,
|
||||
warningMessage = TIMEOUT_MESSAGES.warning,
|
||||
criticalMessage = TIMEOUT_MESSAGES.critical,
|
||||
timeoutMessage = TIMEOUT_MESSAGES.timeout,
|
||||
onWarning,
|
||||
onCritical,
|
||||
onTimeout,
|
||||
} = options;
|
||||
|
||||
let warningShown = false;
|
||||
let criticalShown = false;
|
||||
let warningToastId: string | undefined;
|
||||
let criticalToastId: string | undefined;
|
||||
|
||||
// Calculate warning times
|
||||
const warningTime = timeout * WARNING_THRESHOLDS.warning;
|
||||
const criticalTime = timeout * WARNING_THRESHOLDS.critical;
|
||||
|
||||
// Set up warning timers
|
||||
const warningTimer = setTimeout(() => {
|
||||
if (showWarnings && !warningShown) {
|
||||
warningShown = true;
|
||||
warningToastId = toast.loading(warningMessage, {
|
||||
duration: timeout - warningTime, // Show until timeout or completion
|
||||
});
|
||||
onWarning?.();
|
||||
}
|
||||
}, warningTime);
|
||||
|
||||
const criticalTimer = setTimeout(() => {
|
||||
if (showWarnings && !criticalShown) {
|
||||
criticalShown = true;
|
||||
// Dismiss warning toast if shown
|
||||
if (warningToastId) {
|
||||
toast.dismiss(warningToastId);
|
||||
}
|
||||
criticalToastId = toast.loading(criticalMessage, {
|
||||
duration: timeout - criticalTime, // Show until timeout or completion
|
||||
});
|
||||
onCritical?.();
|
||||
}
|
||||
}, criticalTime);
|
||||
|
||||
// Create timeout promise
|
||||
const timeoutPromise = createTimeoutPromise(timeout, timeoutMessage);
|
||||
|
||||
// Race between the original promise and timeout
|
||||
return Promise.race([promise, timeoutPromise])
|
||||
.then((result) => {
|
||||
// Clear timers if promise resolves before timeout
|
||||
clearTimeout(warningTimer);
|
||||
clearTimeout(criticalTimer);
|
||||
|
||||
// Dismiss any active toasts
|
||||
if (warningToastId) {
|
||||
toast.dismiss(warningToastId);
|
||||
}
|
||||
if (criticalToastId) {
|
||||
toast.dismiss(criticalToastId);
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
// Clear timers
|
||||
clearTimeout(warningTimer);
|
||||
clearTimeout(criticalTimer);
|
||||
|
||||
// Dismiss any active toasts
|
||||
if (warningToastId) {
|
||||
toast.dismiss(warningToastId);
|
||||
}
|
||||
if (criticalToastId) {
|
||||
toast.dismiss(criticalToastId);
|
||||
}
|
||||
|
||||
// Call timeout callback if it's a timeout error
|
||||
if (error.message === timeoutMessage) {
|
||||
onTimeout?.();
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets appropriate timeout for a request type
|
||||
* @param requestType Type of request (fast, normal, slow, verySlow)
|
||||
* @returns Timeout duration in milliseconds
|
||||
*/
|
||||
export function getTimeoutForRequestType(
|
||||
requestType: keyof typeof TIMEOUT_CONFIG = 'normal',
|
||||
): number {
|
||||
return TIMEOUT_CONFIG[requestType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is a timeout error
|
||||
* @param error Error to check
|
||||
* @returns True if error is a timeout error
|
||||
*/
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message === TIMEOUT_MESSAGES.timeout ||
|
||||
error.message.includes('timeout') ||
|
||||
error.message.includes('expired') ||
|
||||
error.name === 'TimeoutError'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Axios timeout errors
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = (error as any).code;
|
||||
return code === 'ECONNABORTED' || code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user-friendly timeout message based on request type
|
||||
* @param requestType Type of request
|
||||
* @returns User-friendly timeout message
|
||||
*/
|
||||
export function getTimeoutMessage(
|
||||
requestType: keyof typeof TIMEOUT_CONFIG = 'normal',
|
||||
): string {
|
||||
const messages: Record<keyof typeof TIMEOUT_CONFIG, string> = {
|
||||
default: 'La requête a expiré. Veuillez réessayer.',
|
||||
fast: 'La requête a expiré. Vérifiez votre connexion et réessayez.',
|
||||
normal: 'La requête a expiré. Veuillez réessayer.',
|
||||
slow: 'L\'opération prend plus de temps que prévu. Veuillez patienter ou réessayer plus tard.',
|
||||
verySlow: 'L\'opération est en cours. Cela peut prendre plusieurs minutes. Veuillez patienter.',
|
||||
};
|
||||
|
||||
return messages[requestType] || messages.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an API call with timeout and warning feedback
|
||||
* @param apiCall The API call function
|
||||
* @param requestType Type of request for timeout configuration
|
||||
* @param options Additional timeout options
|
||||
* @returns Promise with timeout handling
|
||||
*/
|
||||
export function withRequestTimeout<T>(
|
||||
apiCall: () => Promise<T>,
|
||||
requestType: keyof typeof TIMEOUT_CONFIG = 'normal',
|
||||
options: Omit<TimeoutOptions, 'timeout'> = {},
|
||||
): Promise<T> {
|
||||
const timeout = getTimeoutForRequestType(requestType);
|
||||
const timeoutMessage = getTimeoutMessage(requestType);
|
||||
|
||||
return withTimeout(apiCall(), {
|
||||
...options,
|
||||
timeout,
|
||||
timeoutMessage,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue