2025-09-20 11:35:40 +09:00
|
|
|
/**
|
|
|
|
|
* Standardized Error Handling for Portal
|
|
|
|
|
* Provides consistent error handling and user-friendly messages
|
|
|
|
|
*/
|
|
|
|
|
|
2025-09-25 11:44:10 +09:00
|
|
|
import { ApiError as ClientApiError } from "@/lib/api";
|
|
|
|
|
|
|
|
|
|
export interface ApiErrorPayload {
|
2025-09-20 11:35:40 +09:00
|
|
|
success: false;
|
|
|
|
|
error: {
|
|
|
|
|
code: string;
|
|
|
|
|
message: string;
|
|
|
|
|
details?: Record<string, unknown>;
|
|
|
|
|
};
|
2025-09-25 11:44:10 +09:00
|
|
|
timestamp?: string;
|
|
|
|
|
path?: string;
|
2025-09-20 11:35:40 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ApiErrorInfo {
|
|
|
|
|
code: string;
|
|
|
|
|
message: string;
|
|
|
|
|
shouldLogout?: boolean;
|
|
|
|
|
shouldRetry?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract error information from various error types
|
|
|
|
|
*/
|
|
|
|
|
export function getErrorInfo(error: unknown): ApiErrorInfo {
|
2025-09-25 11:44:10 +09:00
|
|
|
if (error instanceof ClientApiError) {
|
|
|
|
|
const info = parseClientApiError(error);
|
|
|
|
|
if (info) {
|
|
|
|
|
return info;
|
|
|
|
|
}
|
2025-09-20 11:35:40 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle fetch/network errors
|
|
|
|
|
if (error instanceof Error) {
|
2025-09-25 17:42:36 +09:00
|
|
|
if (error.name === "TypeError" && error.message.includes("fetch")) {
|
2025-09-20 11:35:40 +09:00
|
|
|
return {
|
2025-09-25 17:42:36 +09:00
|
|
|
code: "NETWORK_ERROR",
|
|
|
|
|
message:
|
|
|
|
|
"Unable to connect to the server. Please check your internet connection and try again.",
|
2025-09-20 11:35:40 +09:00
|
|
|
shouldRetry: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 17:42:36 +09:00
|
|
|
if (error.name === "AbortError") {
|
2025-09-20 11:35:40 +09:00
|
|
|
return {
|
2025-09-25 17:42:36 +09:00
|
|
|
code: "REQUEST_TIMEOUT",
|
|
|
|
|
message: "The request timed out. Please try again.",
|
2025-09-20 11:35:40 +09:00
|
|
|
shouldRetry: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2025-09-25 17:42:36 +09:00
|
|
|
code: "UNKNOWN_ERROR",
|
|
|
|
|
message: "An unexpected error occurred. Please try again.",
|
2025-09-20 11:35:40 +09:00
|
|
|
shouldRetry: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback for unknown error types
|
|
|
|
|
return {
|
2025-09-25 17:42:36 +09:00
|
|
|
code: "UNKNOWN_ERROR",
|
|
|
|
|
message: "An unexpected error occurred. Please try again.",
|
2025-09-20 11:35:40 +09:00
|
|
|
shouldRetry: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 11:44:10 +09:00
|
|
|
export function isApiErrorPayload(error: unknown): error is ApiErrorPayload {
|
2025-09-20 11:35:40 +09:00
|
|
|
return (
|
2025-09-25 17:42:36 +09:00
|
|
|
typeof error === "object" &&
|
2025-09-20 11:35:40 +09:00
|
|
|
error !== null &&
|
2025-09-25 17:42:36 +09:00
|
|
|
"success" in error &&
|
2025-09-25 11:44:10 +09:00
|
|
|
(error as { success?: unknown }).success === false &&
|
2025-09-25 17:42:36 +09:00
|
|
|
"error" in error &&
|
|
|
|
|
typeof (error as { error?: unknown }).error === "object"
|
2025-09-20 11:35:40 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Determine if the user should be logged out for this error
|
|
|
|
|
*/
|
|
|
|
|
function shouldLogoutForError(code: string): boolean {
|
2025-09-25 17:42:36 +09:00
|
|
|
const logoutCodes = ["TOKEN_REVOKED", "INVALID_REFRESH_TOKEN", "UNAUTHORIZED", "SESSION_EXPIRED"];
|
2025-09-20 11:35:40 +09:00
|
|
|
return logoutCodes.includes(code);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Determine if the request should be retried for this error
|
|
|
|
|
*/
|
|
|
|
|
function shouldRetryForError(code: string): boolean {
|
|
|
|
|
const noRetryCodes = [
|
2025-09-25 17:42:36 +09:00
|
|
|
"INVALID_CREDENTIALS",
|
|
|
|
|
"FORBIDDEN",
|
|
|
|
|
"ADMIN_REQUIRED",
|
|
|
|
|
"ACCOUNT_LOCKED",
|
|
|
|
|
"VALIDATION_ERROR",
|
|
|
|
|
"ACCOUNT_ALREADY_LINKED",
|
|
|
|
|
"ACCOUNT_EXISTS",
|
|
|
|
|
"CUSTOMER_NOT_FOUND",
|
|
|
|
|
"INVALID_REQUEST",
|
2025-09-20 11:35:40 +09:00
|
|
|
];
|
|
|
|
|
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);
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-20 11:35:40 +09:00
|
|
|
if (errorInfo.shouldLogout) {
|
|
|
|
|
logout();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a standardized error for logging
|
|
|
|
|
*/
|
2025-09-25 17:42:36 +09:00
|
|
|
export function createErrorLog(
|
|
|
|
|
error: unknown,
|
|
|
|
|
context: string
|
|
|
|
|
): {
|
2025-09-20 11:35:40 +09:00
|
|
|
context: string;
|
|
|
|
|
code: string;
|
|
|
|
|
message: string;
|
|
|
|
|
timestamp: string;
|
|
|
|
|
} {
|
|
|
|
|
const errorInfo = getErrorInfo(error);
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-20 11:35:40 +09:00
|
|
|
return {
|
|
|
|
|
context,
|
|
|
|
|
code: errorInfo.code,
|
|
|
|
|
message: errorInfo.message,
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-09-25 11:44:10 +09:00
|
|
|
|
|
|
|
|
function parseClientApiError(error: ClientApiError): ApiErrorInfo | null {
|
|
|
|
|
const status = error.response?.status;
|
|
|
|
|
const parsedBody = parseRawErrorBody(error.body);
|
|
|
|
|
|
2025-09-25 17:42:36 +09:00
|
|
|
const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null;
|
2025-09-25 11:44:10 +09:00
|
|
|
|
|
|
|
|
if (payloadInfo) {
|
|
|
|
|
return payloadInfo;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2025-09-25 17:42:36 +09:00
|
|
|
code: status ? httpStatusCodeToLabel(status) : "API_ERROR",
|
2025-09-25 11:44:10 +09:00
|
|
|
message: error.message,
|
|
|
|
|
shouldLogout: status === 401,
|
2025-09-25 17:42:36 +09:00
|
|
|
shouldRetry: typeof status === "number" ? status >= 500 : true,
|
2025-09-25 11:44:10 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseRawErrorBody(body: unknown): unknown {
|
|
|
|
|
if (!body) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 17:42:36 +09:00
|
|
|
if (typeof body === "string") {
|
2025-09-25 11:44:10 +09:00
|
|
|
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);
|
2025-09-25 17:42:36 +09:00
|
|
|
const message = payload.message || "Request failed. Please try again.";
|
2025-09-25 11:44:10 +09:00
|
|
|
const derivedStatus = payload.statusCode ?? status;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
code,
|
|
|
|
|
message,
|
|
|
|
|
shouldLogout: shouldLogoutForError(code) || derivedStatus === 401,
|
|
|
|
|
shouldRetry:
|
2025-09-25 17:42:36 +09:00
|
|
|
typeof derivedStatus === "number" ? derivedStatus >= 500 : shouldRetryForError(code),
|
2025-09-25 11:44:10 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
2025-09-25 17:42:36 +09:00
|
|
|
typeof payload === "object" &&
|
2025-09-25 11:44:10 +09:00
|
|
|
payload !== null &&
|
2025-09-25 17:42:36 +09:00
|
|
|
"message" in payload &&
|
|
|
|
|
typeof (payload as { message?: unknown }).message === "string"
|
2025-09-25 11:44:10 +09:00
|
|
|
) {
|
2025-09-25 18:04:47 +09:00
|
|
|
const payloadWithMessage = payload as { code?: unknown; message: string };
|
|
|
|
|
const candidateCode = payloadWithMessage.code;
|
2025-09-25 17:42:36 +09:00
|
|
|
const code =
|
2025-09-25 18:04:47 +09:00
|
|
|
typeof candidateCode === "string" ? candidateCode : httpStatusCodeToLabel(status);
|
2025-09-25 11:44:10 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
code,
|
2025-09-25 18:04:47 +09:00
|
|
|
message: payloadWithMessage.message,
|
2025-09-25 11:44:10 +09:00
|
|
|
shouldLogout: shouldLogoutForError(code) || status === 401,
|
2025-09-25 17:42:36 +09:00
|
|
|
shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code),
|
2025-09-25 11:44:10 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isGlobalErrorPayload(payload: unknown): payload is {
|
|
|
|
|
success: false;
|
|
|
|
|
code?: string;
|
|
|
|
|
message?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
statusCode?: number;
|
|
|
|
|
} {
|
|
|
|
|
return (
|
2025-09-25 17:42:36 +09:00
|
|
|
typeof payload === "object" &&
|
2025-09-25 11:44:10 +09:00
|
|
|
payload !== null &&
|
2025-09-25 17:42:36 +09:00
|
|
|
"success" in payload &&
|
2025-09-25 11:44:10 +09:00
|
|
|
(payload as { success?: unknown }).success === false &&
|
2025-09-25 17:42:36 +09:00
|
|
|
("code" in payload || "message" in payload || "error" in payload)
|
2025-09-25 11:44:10 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function httpStatusCodeToLabel(status?: number): string {
|
|
|
|
|
if (!status) {
|
2025-09-25 17:42:36 +09:00
|
|
|
return "API_ERROR";
|
2025-09-25 11:44:10 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 400:
|
2025-09-25 17:42:36 +09:00
|
|
|
return "BAD_REQUEST";
|
2025-09-25 11:44:10 +09:00
|
|
|
case 401:
|
2025-09-25 17:42:36 +09:00
|
|
|
return "UNAUTHORIZED";
|
2025-09-25 11:44:10 +09:00
|
|
|
case 403:
|
2025-09-25 17:42:36 +09:00
|
|
|
return "FORBIDDEN";
|
2025-09-25 11:44:10 +09:00
|
|
|
case 404:
|
2025-09-25 17:42:36 +09:00
|
|
|
return "NOT_FOUND";
|
2025-09-25 11:44:10 +09:00
|
|
|
case 409:
|
2025-09-25 17:42:36 +09:00
|
|
|
return "CONFLICT";
|
2025-09-25 11:44:10 +09:00
|
|
|
case 422:
|
2025-09-25 17:42:36 +09:00
|
|
|
return "UNPROCESSABLE_ENTITY";
|
2025-09-25 11:44:10 +09:00
|
|
|
default:
|
2025-09-25 17:42:36 +09:00
|
|
|
return status >= 500 ? "SERVER_ERROR" : `HTTP_${status}`;
|
2025-09-25 11:44:10 +09:00
|
|
|
}
|
|
|
|
|
}
|