Assist_Design/apps/portal/src/lib/utils/error-handling.ts

275 lines
6.3 KiB
TypeScript

/**
* 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<string, unknown>;
};
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}`;
}
}