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

268 lines
6.3 KiB
TypeScript
Raw Normal View History

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