275 lines
6.3 KiB
TypeScript
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}`;
|
|
}
|
|
}
|