[FE-API-014] fe-api: Add request timeout handling

This commit is contained in:
senke 2025-12-25 13:22:15 +01:00
parent a350cddaa3
commit 99dbc03ef0
4 changed files with 281 additions and 3 deletions

View file

@ -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",

View file

@ -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, {

View file

@ -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',

View 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,
});
}