import axios, { AxiosError } from 'axios'; import { ProblemDetails } from '../types'; export type ErrorCategory = | 'validation' | 'creation' | 'update' | 'security' | 'notFound' | 'conflict' | 'server' | 'general'; export interface FriendlyError { title: string; message: string; statusCode?: number; hasReturnObject: boolean; isProblemDetails: boolean; category: ErrorCategory; categoryLabel?: string; errorsList?: string[]; fieldErrors?: Array<{ field: string; message: string }>; technicalDetails?: string; } export interface ProblemDetailsResponse { type?: string | null; title?: string | null; status?: number | null; detail?: string | null; details?: string | null; Details?: string | null; instance?: string | null; errors?: Record | any; [key: string]: any; } /** * Checks if a string contains raw HTTP request/response headers or dump. */ export function isRawHttpOutput(text: string): boolean { if (!text || typeof text !== 'string') return false; const headerPatterns = [ /^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im, /(?:authorization:\s*bearer|content-type:\s*application\/)/i, /HTTP\/\d(?:\.\d)?\s+\d{3}/i, ]; let matches = 0; const lines = text.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) { if (headerPatterns.some((p) => p.test(trimmed))) { matches++; } } } return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text)); } /** * Redacts sensitive tokens and credentials from technical traces. */ export function sanitizeTechnicalDetails(text: string): string { if (!text) return ''; return text .replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]') .replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]') .replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"'); } /** * Cleans technical jargon, stack traces, and framework namespaces from text strings. */ export function cleanTechnicalJargon(text: string): string { if (!text) return ''; if (isRawHttpOutput(text)) { return ''; } const cleaned = text .replace(/^AxiosError:\s*/i, '') .replace(/^Error:\s*/i, '') .replace(/Request failed with status code \d+/i, '') .replace(/https?:\/\/[^\s]+/g, '') .replace(/System\.[a-zA-Z0-9_.]+/g, '') .replace(/Microsoft\.[a-zA-Z0-9_.]+/g, '') .replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '') .trim(); // In this system, ProblemDetails title is typically "Error" — ignore trivial or generic "Error" strings if (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') { return ''; } return cleaned; } /** * Safely inspects whether an error contains a valid response body object. * Note: not all error responses will have a return object, so this method explicitly checks. */ export function inspectErrorResponseObject(error: unknown): ProblemDetailsResponse | null { if (!error) return null; // 1. Inspect Axios error or error with response structure const anyErr = error as any; const data = anyErr?.response?.data; if (data && typeof data === 'object') { return data as ProblemDetailsResponse; } // Check if response body is a JSON string if (typeof data === 'string' && data.trim().startsWith('{')) { try { const parsed = JSON.parse(data.trim()); if (parsed && typeof parsed === 'object') { return parsed as ProblemDetailsResponse; } } catch { // Not a JSON object } } // 2. Direct object inspection (e.g. if response.data was passed directly) if (typeof error === 'object' && error !== null) { if ( 'details' in anyErr || 'Details' in anyErr || 'detail' in anyErr || 'Detail' in anyErr || 'status' in anyErr || 'title' in anyErr || 'type' in anyErr || 'errors' in anyErr || 'Errors' in anyErr ) { return anyErr as ProblemDetailsResponse; } } return null; } /** * Checks if a response object conforms to RFC 7807 / RFC 9110 ProblemDetails. */ export function isProblemDetailsObject(data: any): data is ProblemDetails { if (!data || typeof data !== 'object') return false; return ( 'details' in data || 'Details' in data || 'title' in data || 'detail' in data || 'Detail' in data || 'status' in data || 'type' in data || 'errors' in data || 'Errors' in data ); } /** * Parses a comma-delimited error list from the `detail` property. * e.g. "Failed to update organisation" -> ["Failed to update organisation"] * e.g. "Invalid organisation name, Budget exceeds ceiling, Target date cannot be in past" * -> ["Invalid organisation name", "Budget exceeds ceiling", "Target date cannot be in past"] */ export function parseCommaDelimitedErrors(detail?: string | null): string[] { if (!detail || typeof detail !== 'string') return []; const trimmed = detail.trim(); if (!trimmed) return []; // Split by comma const rawParts = trimmed.split(','); const errors: string[] = []; for (const part of rawParts) { const pTrim = part.trim(); if (!pTrim) continue; const cleaned = cleanTechnicalJargon(pTrim); if (cleaned && cleaned.length > 0) { errors.push(cleaned); } else if (pTrim.length > 0 && pTrim.toLowerCase() !== 'error') { errors.push(pTrim); } } return errors.length > 0 ? errors : [trimmed]; } /** * Detects whether the user action context was an entity creation or an update. */ function detectOperationIntent( method?: string, actionTitle?: string, url?: string ): 'creation' | 'update' | 'deletion' | 'general' { const normMethod = (method || '').toUpperCase(); const text = `${actionTitle || ''} ${url || ''}`.toLowerCase(); if ( normMethod === 'POST' || text.includes('create') || text.includes('registration') || text.includes('register') || text.includes('dispatch') || text.includes('add ') || text.includes('new ') || text.includes('upload') || text.includes('record actual') || text.includes('log risk') ) { return 'creation'; } if ( normMethod === 'PUT' || normMethod === 'PATCH' || text.includes('update') || text.includes('edit') || text.includes('save') || text.includes('status') || text.includes('approve') || text.includes('execute') || text.includes('modify') ) { return 'update'; } if (normMethod === 'DELETE' || text.includes('delete') || text.includes('remove')) { return 'deletion'; } return 'general'; } /** * Comprehensive common error handling function across the application. * * Handles standard RFC 9110 / RFC 7807 error responses: * { * "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", * "title": "Error", * "status": 400, * "detail": "Failed to update organisation" * } * * - Parses comma-delimited error list in the `detail` property. * - Handles authentication (401) and forbidden (403) specifically. * - Explicitly inspects whether a return object exists, handling responses without return objects gracefully. */ export function parseFriendlyError( error: unknown, fallbackActionTitle = 'Action Failed' ): FriendlyError { let statusCode: number | undefined; let technicalSummary = ''; let httpMethod = ''; let requestUrl = ''; // 1. Inspect error metadata if available const anyErr = error as any; if (axios.isAxiosError(error)) { const axiosErr = error as AxiosError; statusCode = axiosErr.response?.status; httpMethod = axiosErr.config?.method?.toUpperCase() || ''; requestUrl = axiosErr.config?.url || ''; technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`; // Network disconnection or request timeout (no response received at all) if (!axiosErr.response) { if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) { return { title: 'Request Timed Out', message: 'The server took too long to respond. Please check your network connection and try again.', statusCode: 408, hasReturnObject: false, isProblemDetails: false, category: 'server', categoryLabel: 'Timeout', technicalDetails: technicalSummary || axiosErr.message, }; } return { title: 'Connection Unavailable', message: 'Unable to reach the system service. Please verify your internet connection or try again in a few moments.', statusCode: 0, hasReturnObject: false, isProblemDetails: false, category: 'server', categoryLabel: 'Network', technicalDetails: technicalSummary || axiosErr.message, }; } } else if (anyErr && typeof anyErr === 'object') { statusCode = anyErr.response?.status ?? anyErr.status; httpMethod = anyErr.config?.method?.toUpperCase() || ''; requestUrl = anyErr.config?.url || ''; if (statusCode) { technicalSummary = `HTTP ${statusCode}`; } } // 2. Inspect for returned response object const returnObject = inspectErrorResponseObject(error); const hasReturnObject = returnObject !== null; if (returnObject) { if (returnObject.status && typeof returnObject.status === 'number') { statusCode = returnObject.status; } } // Detect operation intent (creation, update, deletion, general) const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl); // 3. Special handling for Auth (401) and Forbid (403) if (statusCode === 401) { const rawDetail = returnObject?.details ?? returnObject?.Details ?? returnObject?.detail ?? returnObject?.Detail ?? anyErr?.response?.data?.details ?? anyErr?.response?.data?.Details ?? anyErr?.response?.data?.detail ?? anyErr?.details ?? anyErr?.message; const cleanDetail = cleanTechnicalJargon(rawDetail || ''); return { title: 'Authentication Required', message: cleanDetail || 'Your authentication session has expired or is unauthorized (HTTP 401). Please sign in again.', statusCode: 401, hasReturnObject, isProblemDetails: hasReturnObject, category: 'security', categoryLabel: 'Unauthorized', technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 401 Unauthorized', }; } if (statusCode === 403) { const rawDetail = returnObject?.details ?? returnObject?.Details ?? returnObject?.detail ?? returnObject?.Detail ?? anyErr?.response?.data?.details ?? anyErr?.response?.data?.Details ?? anyErr?.response?.data?.detail ?? anyErr?.details ?? anyErr?.message; const detailErrors = parseCommaDelimitedErrors(rawDetail); const friendlyMessage = detailErrors.length > 0 ? detailErrors.length === 1 ? detailErrors[0] : detailErrors.map((err) => `• ${err}`).join('\n') : 'You do not have permission to access this resource or perform this action (HTTP 403).'; return { title: fallbackActionTitle !== 'Action Failed' ? fallbackActionTitle : 'Access Forbidden', message: friendlyMessage, statusCode: 403, hasReturnObject, isProblemDetails: hasReturnObject, category: 'security', categoryLabel: 'Forbidden', errorsList: detailErrors.length > 0 ? detailErrors : undefined, technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 403 Forbidden', }; } // 4. Case A: Response has a valid return object (e.g. RFC 9110 ProblemDetails) if (hasReturnObject && returnObject) { // Priority: 'detail' property from response, then 'details', etc. const rawDetail = returnObject.detail ?? returnObject.Detail ?? returnObject.details ?? returnObject.Details ?? anyErr?.response?.data?.detail ?? anyErr?.response?.data?.Detail ?? anyErr?.response?.data?.details ?? anyErr?.response?.data?.Details ?? anyErr?.detail ?? anyErr?.Detail ?? anyErr?.details ?? anyErr?.Details ?? returnObject.message ?? returnObject.Message ?? returnObject.error ?? returnObject.Error; // Parse errors list from details property const errorsList: string[] = []; if (Array.isArray(rawDetail)) { rawDetail.forEach((e: any) => { const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e)); if (cleaned) errorsList.push(cleaned); }); } else if (typeof rawDetail === 'string' && rawDetail.trim()) { errorsList.push(...parseCommaDelimitedErrors(rawDetail)); } // If details was empty, check if errors array/dictionary exists if (errorsList.length === 0 && returnObject.errors) { if (Array.isArray(returnObject.errors)) { returnObject.errors.forEach((e: any) => { const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e)); if (cleaned) errorsList.push(cleaned); }); } else if (typeof returnObject.errors === 'object') { for (const [key, val] of Object.entries(returnObject.errors)) { if (Array.isArray(val)) { val.forEach((m) => { const cleaned = cleanTechnicalJargon(String(m)); if (cleaned) errorsList.push(`${key}: ${cleaned}`); }); } else if (typeof val === 'string' && val.trim()) { const cleaned = cleanTechnicalJargon(val); if (cleaned) errorsList.push(`${key}: ${cleaned}`); } } } } let category: ErrorCategory = 'general'; let categoryLabel: string = 'Error'; if (statusCode === 400) { if (operationIntent === 'creation') { category = 'creation'; categoryLabel = 'Creation Error'; } else if (operationIntent === 'update') { category = 'update'; categoryLabel = 'Update Error'; } else { category = 'validation'; categoryLabel = 'Validation Error'; } } else if (statusCode === 404) { category = 'notFound'; categoryLabel = 'Not Found'; } else if (statusCode === 409) { category = 'conflict'; categoryLabel = 'Conflict'; } else if (statusCode === 422) { category = 'validation'; categoryLabel = 'Validation Error'; } else if (statusCode && statusCode >= 500) { category = 'server'; categoryLabel = 'Server Error'; } // Format display message from errorsList let friendlyMessage = ''; if (errorsList.length > 1) { friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n'); } else if (errorsList.length === 1) { friendlyMessage = errorsList[0]; } else { // Fallback message ONLY if details property was empty friendlyMessage = operationIntent === 'creation' ? 'Failed to create record. Please verify the submitted parameters.' : operationIntent === 'update' ? 'Failed to update record. Please verify the submitted parameters.' : 'Request could not be processed due to invalid parameters.'; } // Use contextual action title (or returnObject.title if not "Error") let displayTitle = fallbackActionTitle; if (displayTitle === 'Action Failed' && returnObject.title && returnObject.title.toLowerCase() !== 'error') { displayTitle = returnObject.title; } return { title: displayTitle, message: friendlyMessage, statusCode, hasReturnObject: true, isProblemDetails: true, category, categoryLabel, errorsList: errorsList.length > 0 ? errorsList : undefined, technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined), }; } // 5. Case B: Error response DOES NOT have a return object (e.g. empty response, network, non-JSON 500/502/503) let title = fallbackActionTitle || 'Action Failed'; let message = ''; let category: ErrorCategory = 'general'; let categoryLabel: string = 'Error'; // Check if error or response data has a detail or details property const directDetail = anyErr?.response?.data?.detail ?? anyErr?.response?.data?.Detail ?? anyErr?.response?.data?.details ?? anyErr?.response?.data?.Details ?? anyErr?.detail ?? anyErr?.Detail ?? anyErr?.details ?? anyErr?.Details; const fallbackErrorsList: string[] = []; if (directDetail) { if (typeof directDetail === 'string' && directDetail.trim()) { fallbackErrorsList.push(...parseCommaDelimitedErrors(directDetail.trim())); message = fallbackErrorsList.join('\n'); } else if (Array.isArray(directDetail)) { fallbackErrorsList.push(...directDetail.map(String)); message = fallbackErrorsList.join('\n'); } } if (!message) { switch (statusCode) { case 400: category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation'; categoryLabel = 'Bad Request'; message = operationIntent === 'creation' ? 'The new record could not be created. Please review the submitted parameters.' : operationIntent === 'update' ? 'The record could not be updated. Please verify the submitted parameters.' : 'The request could not be processed due to invalid parameters.'; break; case 404: title = 'Record Not Found'; category = 'notFound'; categoryLabel = 'Not Found'; message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.'; break; case 409: title = 'Resource Conflict'; category = 'conflict'; categoryLabel = 'Conflict'; message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.'; break; case 422: category = 'validation'; categoryLabel = 'Incomplete Data'; message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.'; break; case 429: title = 'Rate Limit Exceeded'; category = 'server'; categoryLabel = 'Rate Limit'; message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.'; break; case 500: case 502: case 503: case 504: title = 'Service Temporarily Unavailable'; category = 'server'; categoryLabel = 'Service Issue'; message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`; break; default: if (typeof error === 'string' && error.trim()) { message = cleanTechnicalJargon(error); } else if (error instanceof Error && error.message) { message = cleanTechnicalJargon(error.message); } if (!message) { message = statusCode ? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.` : 'An unexpected issue occurred while processing this request. Please try again.'; } break; } } return { title, message, statusCode, hasReturnObject: fallbackErrorsList.length > 0, isProblemDetails: false, category, categoryLabel, errorsList: fallbackErrorsList.length > 0 ? fallbackErrorsList : undefined, technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined), }; }