566 lines
18 KiB
TypeScript
566 lines
18 KiB
TypeScript
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<string, string[] | string> | 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();
|
|
|
|
if (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') {
|
|
return '';
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
/**
|
|
* Safely inspects whether an error contains a valid response body object.
|
|
*/
|
|
export function inspectErrorResponseObject(error: unknown): ProblemDetailsResponse | null {
|
|
if (!error) return null;
|
|
|
|
const anyErr = error as any;
|
|
const data = anyErr?.response?.data;
|
|
if (data && typeof data === 'object') {
|
|
return data as ProblemDetailsResponse;
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
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.
|
|
*/
|
|
export function parseCommaDelimitedErrors(detail?: string | null): string[] {
|
|
if (!detail || typeof detail !== 'string') return [];
|
|
const trimmed = detail.trim();
|
|
if (!trimmed) return [];
|
|
|
|
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];
|
|
}
|
|
|
|
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 RFC 9110 / RFC 7807 ProblemDetails and non-technical language.
|
|
*/
|
|
export function parseFriendlyError(
|
|
error: unknown,
|
|
fallbackActionTitle = 'Action Failed'
|
|
): FriendlyError {
|
|
let statusCode: number | undefined;
|
|
let technicalSummary = '';
|
|
let httpMethod = '';
|
|
let requestUrl = '';
|
|
|
|
const anyErr = error as any;
|
|
if (axios.isAxiosError(error)) {
|
|
const axiosErr = error as AxiosError<any>;
|
|
statusCode = axiosErr.response?.status;
|
|
httpMethod = axiosErr.config?.method?.toUpperCase() || '';
|
|
requestUrl = axiosErr.config?.url || '';
|
|
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
|
|
|
|
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}`;
|
|
}
|
|
}
|
|
|
|
const returnObject = inspectErrorResponseObject(error);
|
|
const hasReturnObject = returnObject !== null;
|
|
|
|
if (returnObject) {
|
|
if (returnObject.status && typeof returnObject.status === 'number') {
|
|
statusCode = returnObject.status;
|
|
}
|
|
}
|
|
|
|
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
|
|
|
|
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',
|
|
};
|
|
}
|
|
|
|
if (hasReturnObject && returnObject) {
|
|
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;
|
|
|
|
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 (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';
|
|
}
|
|
|
|
let friendlyMessage = '';
|
|
if (errorsList.length > 1) {
|
|
friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n');
|
|
} else if (errorsList.length === 1) {
|
|
friendlyMessage = errorsList[0];
|
|
} else {
|
|
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.';
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
let title = fallbackActionTitle || 'Action Failed';
|
|
let message = '';
|
|
let category: ErrorCategory = 'general';
|
|
let categoryLabel: string = 'Error';
|
|
|
|
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),
|
|
};
|
|
}
|
|
|