veza/apps/web/src/utils/timeoutHandler.ts

263 lines
7.2 KiB
TypeScript
Raw Normal View History

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