/** * 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 payloadWithMessage = payload as { code?: unknown; message: string }; const candidateCode = payloadWithMessage.code; const code = typeof candidateCode === "string" ? candidateCode : httpStatusCodeToLabel(status); return { code, message: payloadWithMessage.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}`; } }