/** * Standardized Error Handling for Portal * Provides consistent error handling and user-friendly messages */ import { ApiError as ClientApiError } from "@/lib/api"; export interface ApiErrorPayload { success: false; error: { code: string; message: string; details?: Record; }; timestamp?: string; path?: string; } export interface ApiErrorInfo { code: string; message: string; shouldLogout?: boolean; shouldRetry?: boolean; } /** * Extract error information from various error types */ export function getErrorInfo(error: unknown): ApiErrorInfo { if (error instanceof ClientApiError) { const info = parseClientApiError(error); if (info) { return info; } } // Handle fetch/network errors if (error instanceof Error) { if (error.name === 'TypeError' && error.message.includes('fetch')) { return { code: 'NETWORK_ERROR', message: 'Unable to connect to the server. Please check your internet connection and try again.', shouldRetry: true, }; } if (error.name === 'AbortError') { return { code: 'REQUEST_TIMEOUT', message: 'The request timed out. Please try again.', shouldRetry: true, }; } return { code: 'UNKNOWN_ERROR', message: 'An unexpected error occurred. Please try again.', shouldRetry: true, }; } // Fallback for unknown error types return { code: 'UNKNOWN_ERROR', message: 'An unexpected error occurred. Please try again.', shouldRetry: true, }; } export function isApiErrorPayload(error: unknown): error is ApiErrorPayload { return ( typeof error === 'object' && error !== null && 'success' in error && (error as { success?: unknown }).success === false && 'error' in error && typeof (error as { error?: unknown }).error === 'object' ); } /** * Determine if the user should be logged out for this error */ function shouldLogoutForError(code: string): boolean { const logoutCodes = [ 'TOKEN_REVOKED', 'INVALID_REFRESH_TOKEN', 'UNAUTHORIZED', 'SESSION_EXPIRED', ]; return logoutCodes.includes(code); } /** * Determine if the request should be retried for this error */ function shouldRetryForError(code: string): boolean { const noRetryCodes = [ 'INVALID_CREDENTIALS', 'FORBIDDEN', 'ADMIN_REQUIRED', 'ACCOUNT_LOCKED', 'VALIDATION_ERROR', 'ACCOUNT_ALREADY_LINKED', 'ACCOUNT_EXISTS', 'CUSTOMER_NOT_FOUND', 'INVALID_REQUEST', ]; return !noRetryCodes.includes(code); } /** * Get a user-friendly error message for display in UI */ export function getUserFriendlyMessage(error: unknown): string { const errorInfo = getErrorInfo(error); return errorInfo.message; } /** * Handle authentication errors consistently */ export function handleAuthError(error: unknown, logout: () => void): void { const errorInfo = getErrorInfo(error); if (errorInfo.shouldLogout) { logout(); } } /** * Create a standardized error for logging */ export function createErrorLog(error: unknown, context: string): { context: string; code: string; message: string; timestamp: string; } { const errorInfo = getErrorInfo(error); return { context, code: errorInfo.code, message: errorInfo.message, timestamp: new Date().toISOString(), }; } function parseClientApiError(error: ClientApiError): ApiErrorInfo | null { const status = error.response?.status; const parsedBody = parseRawErrorBody(error.body); const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null; if (payloadInfo) { return payloadInfo; } return { code: status ? httpStatusCodeToLabel(status) : 'API_ERROR', message: error.message, shouldLogout: status === 401, shouldRetry: typeof status === 'number' ? status >= 500 : true, }; } function parseRawErrorBody(body: unknown): unknown { if (!body) { return null; } if (typeof body === 'string') { try { return JSON.parse(body); } catch { return body; } } return body; } function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null { if (isApiErrorPayload(payload)) { const code = payload.error.code; return { code, message: payload.error.message, shouldLogout: shouldLogoutForError(code) || status === 401, shouldRetry: shouldRetryForError(code), }; } if (isGlobalErrorPayload(payload)) { const code = payload.code || payload.error || httpStatusCodeToLabel(status); const message = payload.message || 'Request failed. Please try again.'; const derivedStatus = payload.statusCode ?? status; return { code, message, shouldLogout: shouldLogoutForError(code) || derivedStatus === 401, shouldRetry: typeof derivedStatus === 'number' ? derivedStatus >= 500 : shouldRetryForError(code), }; } if ( typeof payload === 'object' && payload !== null && 'message' in payload && typeof (payload as { message?: unknown }).message === 'string' ) { const code = typeof (payload as { code?: unknown }).code === 'string' ? (payload as { code: string }).code : httpStatusCodeToLabel(status); return { code, message: (payload as { message: string }).message, shouldLogout: shouldLogoutForError(code) || status === 401, shouldRetry: typeof status === 'number' ? status >= 500 : shouldRetryForError(code), }; } return null; } function isGlobalErrorPayload(payload: unknown): payload is { success: false; code?: string; message?: string; error?: string; statusCode?: number; } { return ( typeof payload === 'object' && payload !== null && 'success' in payload && (payload as { success?: unknown }).success === false && ('code' in payload || 'message' in payload || 'error' in payload) ); } function httpStatusCodeToLabel(status?: number): string { if (!status) { return 'API_ERROR'; } switch (status) { case 400: return 'BAD_REQUEST'; case 401: return 'UNAUTHORIZED'; case 403: return 'FORBIDDEN'; case 404: return 'NOT_FOUND'; case 409: return 'CONFLICT'; case 422: return 'UNPROCESSABLE_ENTITY'; default: return status >= 500 ? 'SERVER_ERROR' : `HTTP_${status}`; } }