Implement DomainHttpException for stable error handling and refactor exception filter
- Introduced `DomainHttpException` to standardize HTTP error responses with explicit domain error codes. - Refactored `UnifiedExceptionFilter` to extract detailed exception information, including optional explicit error codes. - Updated `WhmcsErrorHandlerService` to utilize `DomainHttpException` for better error normalization and handling. - Removed deprecated error pattern matching logic to enforce explicit error codes in the system. - Enhanced logging in `WhmcsHttpClientService` to differentiate between expected business outcomes and actual errors.
This commit is contained in:
parent
c13a327a07
commit
ad6fadb015
21
apps/bff/src/core/http/domain-http.exception.ts
Normal file
21
apps/bff/src/core/http/domain-http.exception.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { HttpException } from "@nestjs/common";
|
||||||
|
import type { HttpStatus } from "@nestjs/common";
|
||||||
|
import { ErrorMessages, type ErrorCodeType } from "@customer-portal/domain/common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DomainHttpException
|
||||||
|
*
|
||||||
|
* Use this when you want to throw an HTTP error with an explicit, stable domain error code.
|
||||||
|
* The global exception filter will read `code` from the exception response and format the
|
||||||
|
* standard API error response for the client.
|
||||||
|
*/
|
||||||
|
export class DomainHttpException extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly code: ErrorCodeType,
|
||||||
|
status: HttpStatus,
|
||||||
|
message?: string
|
||||||
|
) {
|
||||||
|
// `message` is optional; if omitted we fall back to the shared user-facing message.
|
||||||
|
super({ code, message: message ?? ErrorMessages[code] }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,7 +14,6 @@ import {
|
|||||||
ErrorCode,
|
ErrorCode,
|
||||||
ErrorMessages,
|
ErrorMessages,
|
||||||
ErrorMetadata,
|
ErrorMetadata,
|
||||||
matchErrorPattern,
|
|
||||||
type ErrorCodeType,
|
type ErrorCodeType,
|
||||||
type ApiError,
|
type ApiError,
|
||||||
} from "@customer-portal/domain/common";
|
} from "@customer-portal/domain/common";
|
||||||
@ -78,65 +77,92 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
|||||||
} {
|
} {
|
||||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
let originalMessage = "An unexpected error occurred";
|
let originalMessage = "An unexpected error occurred";
|
||||||
|
let explicitCode: ErrorCodeType | undefined;
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
status = exception.getStatus();
|
status = exception.getStatus();
|
||||||
originalMessage = this.extractExceptionMessage(exception);
|
const extracted = this.extractExceptionDetails(exception);
|
||||||
|
originalMessage = extracted.message;
|
||||||
|
explicitCode = extracted.code;
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
originalMessage = exception.message;
|
originalMessage = exception.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map to error code
|
// Map to error code
|
||||||
const errorCode = this.mapToErrorCode(originalMessage, status);
|
const errorCode = explicitCode ?? this.mapStatusToErrorCode(status);
|
||||||
|
|
||||||
return { status, errorCode, originalMessage };
|
return { status, errorCode, originalMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract message from HttpException
|
* Extract message (and optionally an explicit error code) from HttpException.
|
||||||
*/
|
*/
|
||||||
private extractExceptionMessage(exception: HttpException): string {
|
private extractExceptionDetails(exception: HttpException): {
|
||||||
|
message: string;
|
||||||
|
code?: ErrorCodeType;
|
||||||
|
} {
|
||||||
const response = exception.getResponse();
|
const response = exception.getResponse();
|
||||||
|
|
||||||
if (typeof response === "string") {
|
if (typeof response === "string") {
|
||||||
return response;
|
return { message: response };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof response === "object" && response !== null) {
|
if (typeof response === "object" && response !== null) {
|
||||||
const responseObj = response as Record<string, unknown>;
|
const responseObj = response as Record<string, unknown>;
|
||||||
|
const code = this.extractExplicitCode(responseObj);
|
||||||
|
|
||||||
// Handle NestJS validation errors (array of messages)
|
// Handle NestJS validation errors (array of messages)
|
||||||
if (Array.isArray(responseObj.message)) {
|
if (Array.isArray(responseObj.message)) {
|
||||||
const firstMessage = responseObj.message.find((m): m is string => typeof m === "string");
|
const firstMessage = responseObj.message.find((m): m is string => typeof m === "string");
|
||||||
if (firstMessage) return firstMessage;
|
if (firstMessage) return { message: firstMessage, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle standard message field
|
// Handle standard message field
|
||||||
if (typeof responseObj.message === "string") {
|
if (typeof responseObj.message === "string") {
|
||||||
return responseObj.message;
|
return { message: responseObj.message, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle error field
|
// Handle error field
|
||||||
if (typeof responseObj.error === "string") {
|
if (typeof responseObj.error === "string") {
|
||||||
return responseObj.error;
|
return { message: responseObj.error, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { message: exception.message, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
return exception.message;
|
return { message: exception.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map error message and status to error code
|
* Extract explicit error code from HttpException response bodies.
|
||||||
*/
|
*/
|
||||||
private mapToErrorCode(message: string, status: number): ErrorCodeType {
|
private extractExplicitCode(responseObj: Record<string, unknown>): ErrorCodeType | undefined {
|
||||||
// First, try pattern matching on the message
|
// 1) Preferred: { code: "AUTH_003" }
|
||||||
const patternCode = matchErrorPattern(message);
|
if (typeof responseObj.code === "string" && this.isKnownErrorCode(responseObj.code)) {
|
||||||
if (patternCode !== ErrorCode.UNKNOWN) {
|
return responseObj.code as ErrorCodeType;
|
||||||
return patternCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to status code mapping
|
// 2) Standard API error format: { error: { code: "AUTH_003" } }
|
||||||
// Cast status to HttpStatus to satisfy TypeScript enum comparison
|
const maybeError = responseObj.error;
|
||||||
|
if (maybeError && typeof maybeError === "object") {
|
||||||
|
const errorObj = maybeError as Record<string, unknown>;
|
||||||
|
if (typeof errorObj.code === "string" && this.isKnownErrorCode(errorObj.code)) {
|
||||||
|
return errorObj.code as ErrorCodeType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isKnownErrorCode(value: string): boolean {
|
||||||
|
// ErrorMessages is keyed by ErrorCodeType
|
||||||
|
return Object.prototype.hasOwnProperty.call(ErrorMessages, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map status code to error code (no message parsing).
|
||||||
|
*/
|
||||||
|
private mapStatusToErrorCode(status: number): ErrorCodeType {
|
||||||
switch (status as HttpStatus) {
|
switch (status as HttpStatus) {
|
||||||
case HttpStatus.UNAUTHORIZED:
|
case HttpStatus.UNAUTHORIZED:
|
||||||
return ErrorCode.SESSION_EXPIRED;
|
return ErrorCode.SESSION_EXPIRED;
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
|
import { Injectable, HttpStatus } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
Injectable,
|
ErrorCode,
|
||||||
NotFoundException,
|
type ErrorCodeType,
|
||||||
BadRequestException,
|
type WhmcsErrorResponse,
|
||||||
UnauthorizedException,
|
} from "@customer-portal/domain/common";
|
||||||
} from "@nestjs/common";
|
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { WhmcsErrorResponse } from "@customer-portal/domain/common";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for handling and normalizing WHMCS API errors
|
* Service for handling and normalizing WHMCS API errors
|
||||||
@ -24,69 +24,72 @@ export class WhmcsErrorHandlerService {
|
|||||||
const message = errorResponse.message;
|
const message = errorResponse.message;
|
||||||
const errorCode = errorResponse.errorcode;
|
const errorCode = errorResponse.errorcode;
|
||||||
|
|
||||||
// Normalize common, expected error responses to domain exceptions
|
const mapped = this.mapProviderErrorToDomain(action, message, errorCode, params);
|
||||||
if (this.isNotFoundError(action, message)) {
|
throw new DomainHttpException(mapped.code, mapped.status);
|
||||||
throw this.createNotFoundException(action, message, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isAuthenticationError(message, errorCode)) {
|
|
||||||
throw new UnauthorizedException(`WHMCS Authentication Error: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isValidationError(message, errorCode)) {
|
|
||||||
throw new BadRequestException(`WHMCS Validation Error: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic WHMCS API error
|
|
||||||
throw new Error(`WHMCS API Error [${action}]: ${message} (${errorCode || "unknown"})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle general request errors (network, timeout, etc.)
|
* Handle general request errors (network, timeout, etc.)
|
||||||
*/
|
*/
|
||||||
handleRequestError(error: unknown, action: string, _params: Record<string, unknown>): never {
|
handleRequestError(error: unknown, _action: string, _params: Record<string, unknown>): never {
|
||||||
const message = getErrorMessage(error);
|
|
||||||
|
|
||||||
if (this.isTimeoutError(error)) {
|
if (this.isTimeoutError(error)) {
|
||||||
throw new Error(`WHMCS API timeout [${action}]: Request timed out`);
|
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isNetworkError(error)) {
|
if (this.isNetworkError(error)) {
|
||||||
throw new Error(`WHMCS API network error [${action}]: ${message}`);
|
throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-throw the original error if it's already a known exception type
|
// If upstream already threw a DomainHttpException or HttpException with code,
|
||||||
if (this.isKnownException(error)) {
|
// let the global exception filter handle it.
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic request error
|
private mapProviderErrorToDomain(
|
||||||
throw new Error(`WHMCS API request failed [${action}]: ${message}`);
|
action: string,
|
||||||
|
message: string,
|
||||||
|
providerErrorCode: string | undefined,
|
||||||
|
params: Record<string, unknown>
|
||||||
|
): { code: ErrorCodeType; status: HttpStatus } {
|
||||||
|
// 1) ValidateLogin: user credentials are wrong (expected)
|
||||||
|
if (
|
||||||
|
action === "ValidateLogin" &&
|
||||||
|
this.isValidateLoginInvalidCredentials(message, providerErrorCode)
|
||||||
|
) {
|
||||||
|
return { code: ErrorCode.INVALID_CREDENTIALS, status: HttpStatus.UNAUTHORIZED };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Not-found style outcomes (expected for some reads)
|
||||||
|
if (this.isNotFoundError(action, message)) {
|
||||||
|
return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) WHMCS API key auth failures: external service/config problem (not end-user auth)
|
||||||
|
if (this.isAuthenticationError(message, providerErrorCode)) {
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Validation failures: treat as bad request
|
||||||
|
if (this.isValidationError(message, providerErrorCode)) {
|
||||||
|
return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Default: external service error
|
||||||
|
void params; // reserved for future mapping detail; keep signature stable
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error indicates a not found condition
|
|
||||||
*/
|
|
||||||
private isNotFoundError(action: string, message: string): boolean {
|
private isNotFoundError(action: string, message: string): boolean {
|
||||||
const lowerMessage = message.toLowerCase();
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
// Client not found errors
|
if (action === "GetClientsDetails" && lowerMessage.includes("client not found")) return true;
|
||||||
if (action === "GetClientsDetails" && lowerMessage.includes("client not found")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invoice not found errors
|
|
||||||
if (
|
if (
|
||||||
(action === "GetInvoice" || action === "UpdateInvoice") &&
|
(action === "GetInvoice" || action === "UpdateInvoice") &&
|
||||||
lowerMessage.includes("invoice not found")
|
lowerMessage.includes("invoice not found")
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (action === "GetClientsProducts" && lowerMessage.includes("no products found")) return true;
|
||||||
// Product not found errors
|
|
||||||
if (action === "GetClientsProducts" && lowerMessage.includes("no products found")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -123,40 +126,19 @@ export class WhmcsErrorHandlerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create appropriate NotFoundException based on action and params
|
* ValidateLogin returns user-facing "invalid credentials" strings (e.g. "Email or Password Invalid").
|
||||||
|
* Treat these as authentication failures rather than external service errors.
|
||||||
*/
|
*/
|
||||||
private createNotFoundException(
|
private isValidateLoginInvalidCredentials(message: string, errorCode?: string): boolean {
|
||||||
action: string,
|
const lowerMessage = message.toLowerCase();
|
||||||
message: string,
|
|
||||||
params: Record<string, unknown>
|
|
||||||
): NotFoundException {
|
|
||||||
if (action === "GetClientsDetails") {
|
|
||||||
const emailParam = params["email"];
|
|
||||||
if (typeof emailParam === "string") {
|
|
||||||
return new NotFoundException(`Client with email ${emailParam} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientIdParam = params["clientid"];
|
// WHMCS commonly responds with: "Email or Password Invalid"
|
||||||
const identifier =
|
if (lowerMessage.includes("email or password invalid")) return true;
|
||||||
typeof clientIdParam === "string" || typeof clientIdParam === "number"
|
if (lowerMessage.includes("password invalid")) return true;
|
||||||
? clientIdParam
|
if (lowerMessage.includes("invalid login")) return true;
|
||||||
: "unknown";
|
if (errorCode === "EMAIL_OR_PASSWORD_INVALID") return true;
|
||||||
|
|
||||||
return new NotFoundException(`Client with ID ${identifier} not found`);
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "GetInvoice" || action === "UpdateInvoice") {
|
|
||||||
const invoiceIdParam = params["invoiceid"];
|
|
||||||
const identifier =
|
|
||||||
typeof invoiceIdParam === "string" || typeof invoiceIdParam === "number"
|
|
||||||
? invoiceIdParam
|
|
||||||
: "unknown";
|
|
||||||
|
|
||||||
return new NotFoundException(`Invoice with ID ${identifier} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic not found error
|
|
||||||
return new NotFoundException(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -185,33 +167,10 @@ export class WhmcsErrorHandlerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error is already a known NestJS exception
|
|
||||||
*/
|
|
||||||
private isKnownException(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof NotFoundException ||
|
|
||||||
error instanceof BadRequestException ||
|
|
||||||
error instanceof UnauthorizedException
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user-friendly error message for client consumption
|
* Get user-friendly error message for client consumption
|
||||||
*/
|
*/
|
||||||
getUserFriendlyMessage(error: unknown): string {
|
getUserFriendlyMessage(error: unknown): string {
|
||||||
if (error instanceof NotFoundException) {
|
|
||||||
return "The requested resource was not found.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof BadRequestException) {
|
|
||||||
return "The request contains invalid data.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof UnauthorizedException) {
|
|
||||||
return "Authentication failed. Please check your credentials.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = getErrorMessage(error).toLowerCase();
|
const message = getErrorMessage(error).toLowerCase();
|
||||||
|
|
||||||
if (message.includes("timeout")) {
|
if (message.includes("timeout")) {
|
||||||
|
|||||||
@ -274,11 +274,13 @@ export class WhmcsHttpClientService {
|
|||||||
const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
|
const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
|
||||||
const errorCode = this.toDisplayString(errorcode, "unknown");
|
const errorCode = this.toDisplayString(errorcode, "unknown");
|
||||||
|
|
||||||
this.logger.error(`WHMCS API returned error [${action}]`, {
|
// Many WHMCS "result=error" responses are expected business outcomes (e.g. invalid credentials).
|
||||||
|
// Log as warning (not error) to avoid spamming error logs; the unified exception filter will
|
||||||
|
// still emit the request-level log based on the mapped error code.
|
||||||
|
this.logger.warn(`WHMCS API returned error [${action}]`, {
|
||||||
errorMessage,
|
errorMessage,
|
||||||
errorCode,
|
errorCode,
|
||||||
params: this.sanitizeLogParams(params),
|
params: this.sanitizeLogParams(params),
|
||||||
fullResponse: parsedResponse,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return error response for the orchestrator to handle with proper exception types
|
// Return error response for the orchestrator to handle with proper exception types
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
ErrorCode,
|
ErrorCode,
|
||||||
ErrorMessages,
|
ErrorMessages,
|
||||||
ErrorMetadata,
|
ErrorMetadata,
|
||||||
matchErrorPattern,
|
|
||||||
type ErrorCodeType,
|
type ErrorCodeType,
|
||||||
} from "@customer-portal/domain/common";
|
} from "@customer-portal/domain/common";
|
||||||
|
|
||||||
@ -46,13 +45,11 @@ export function parseError(error: unknown): ParsedError {
|
|||||||
|
|
||||||
// Handle string errors
|
// Handle string errors
|
||||||
if (typeof error === "string") {
|
if (typeof error === "string") {
|
||||||
const code = matchErrorPattern(error);
|
|
||||||
const metadata = ErrorMetadata[code];
|
|
||||||
return {
|
return {
|
||||||
code,
|
code: ErrorCode.UNKNOWN,
|
||||||
message: error,
|
message: error,
|
||||||
shouldLogout: metadata.shouldLogout,
|
shouldLogout: false,
|
||||||
shouldRetry: metadata.shouldRetry,
|
shouldRetry: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,11 +74,7 @@ function parseApiError(error: ClientApiError): ParsedError {
|
|||||||
const bodyObj = body as Record<string, unknown>;
|
const bodyObj = body as Record<string, unknown>;
|
||||||
|
|
||||||
// Check for standard { success: false, error: { code, message } } format
|
// Check for standard { success: false, error: { code, message } } format
|
||||||
if (
|
if (bodyObj.success === false && bodyObj.error && typeof bodyObj.error === "object") {
|
||||||
bodyObj.success === false &&
|
|
||||||
bodyObj.error &&
|
|
||||||
typeof bodyObj.error === "object"
|
|
||||||
) {
|
|
||||||
const errorObj = bodyObj.error as Record<string, unknown>;
|
const errorObj = bodyObj.error as Record<string, unknown>;
|
||||||
const code = typeof errorObj.code === "string" ? errorObj.code : undefined;
|
const code = typeof errorObj.code === "string" ? errorObj.code : undefined;
|
||||||
const message = typeof errorObj.message === "string" ? errorObj.message : undefined;
|
const message = typeof errorObj.message === "string" ? errorObj.message : undefined;
|
||||||
@ -97,18 +90,8 @@ function parseApiError(error: ClientApiError): ParsedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try extracting message from body
|
// No message-based code inference. If the response doesn't include a structured error code,
|
||||||
const extractedMessage = extractMessageFromBody(body);
|
// we fall back to status-based mapping below.
|
||||||
if (extractedMessage) {
|
|
||||||
const code = matchErrorPattern(extractedMessage);
|
|
||||||
const metadata = ErrorMetadata[code];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
message: extractedMessage,
|
|
||||||
shouldLogout: metadata.shouldLogout,
|
|
||||||
shouldRetry: metadata.shouldRetry,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to status code mapping
|
// Fall back to status code mapping
|
||||||
@ -146,46 +129,14 @@ function parseNativeError(error: Error): ParsedError {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try pattern matching on error message
|
|
||||||
const code = matchErrorPattern(error.message);
|
|
||||||
const metadata = ErrorMetadata[code];
|
|
||||||
return {
|
return {
|
||||||
code,
|
code: ErrorCode.UNKNOWN,
|
||||||
message: code === ErrorCode.UNKNOWN ? error.message : ErrorMessages[code],
|
message: error.message || ErrorMessages[ErrorCode.UNKNOWN],
|
||||||
shouldLogout: metadata.shouldLogout,
|
shouldLogout: false,
|
||||||
shouldRetry: metadata.shouldRetry,
|
shouldRetry: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract error message from response body
|
|
||||||
*/
|
|
||||||
function extractMessageFromBody(body: unknown): string | null {
|
|
||||||
if (!body || typeof body !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyObj = body as Record<string, unknown>;
|
|
||||||
|
|
||||||
// Check nested error.message (standard format)
|
|
||||||
if (bodyObj.error && typeof bodyObj.error === "object") {
|
|
||||||
const errorObj = bodyObj.error as Record<string, unknown>;
|
|
||||||
if (typeof errorObj.message === "string") {
|
|
||||||
return errorObj.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check top-level message
|
|
||||||
if (typeof bodyObj.message === "string") {
|
|
||||||
return bodyObj.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map HTTP status code to error code
|
|
||||||
*/
|
|
||||||
function mapStatusToErrorCode(status?: number): ErrorCodeType {
|
function mapStatusToErrorCode(status?: number): ErrorCodeType {
|
||||||
if (!status) return ErrorCode.UNKNOWN;
|
if (!status) return ErrorCode.UNKNOWN;
|
||||||
|
|
||||||
@ -249,6 +200,5 @@ export {
|
|||||||
ErrorCode,
|
ErrorCode,
|
||||||
ErrorMessages,
|
ErrorMessages,
|
||||||
ErrorMetadata,
|
ErrorMetadata,
|
||||||
matchErrorPattern,
|
|
||||||
type ErrorCodeType,
|
type ErrorCodeType,
|
||||||
} from "@customer-portal/domain/common";
|
} from "@customer-portal/domain/common";
|
||||||
|
|||||||
@ -109,16 +109,19 @@ export const ErrorMessages: Record<ErrorCodeType, string> = {
|
|||||||
[ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.",
|
[ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.",
|
||||||
[ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.",
|
[ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.",
|
||||||
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
|
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
|
||||||
[ErrorCode.SERVICE_UNAVAILABLE]: "This service is temporarily unavailable. Please try again later.",
|
[ErrorCode.SERVICE_UNAVAILABLE]:
|
||||||
|
"This service is temporarily unavailable. Please try again later.",
|
||||||
|
|
||||||
// System
|
// System
|
||||||
[ErrorCode.INTERNAL_ERROR]: "An unexpected error occurred. Please try again later.",
|
[ErrorCode.INTERNAL_ERROR]: "An unexpected error occurred. Please try again later.",
|
||||||
[ErrorCode.EXTERNAL_SERVICE_ERROR]: "An external service is temporarily unavailable. Please try again later.",
|
[ErrorCode.EXTERNAL_SERVICE_ERROR]:
|
||||||
|
"An external service is temporarily unavailable. Please try again later.",
|
||||||
[ErrorCode.DATABASE_ERROR]: "A system error occurred. Please try again later.",
|
[ErrorCode.DATABASE_ERROR]: "A system error occurred. Please try again later.",
|
||||||
[ErrorCode.CONFIGURATION_ERROR]: "A system configuration error occurred. Please contact support.",
|
[ErrorCode.CONFIGURATION_ERROR]: "A system configuration error occurred. Please contact support.",
|
||||||
|
|
||||||
// Network
|
// Network
|
||||||
[ErrorCode.NETWORK_ERROR]: "Unable to connect to the server. Please check your internet connection.",
|
[ErrorCode.NETWORK_ERROR]:
|
||||||
|
"Unable to connect to the server. Please check your internet connection.",
|
||||||
[ErrorCode.TIMEOUT]: "The request timed out. Please try again.",
|
[ErrorCode.TIMEOUT]: "The request timed out. Please try again.",
|
||||||
[ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.",
|
[ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.",
|
||||||
|
|
||||||
@ -162,7 +165,9 @@ export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
|
|||||||
severity: "low",
|
severity: "low",
|
||||||
shouldLogout: true,
|
shouldLogout: true,
|
||||||
shouldRetry: false,
|
shouldRetry: false,
|
||||||
logLevel: "info",
|
// Session expiry is an expected flow (browser tabs, refresh loops, etc.) and can be extremely noisy.
|
||||||
|
// Keep it available for debugging but avoid spamming production logs at info level.
|
||||||
|
logLevel: "debug",
|
||||||
},
|
},
|
||||||
[ErrorCode.TOKEN_INVALID]: {
|
[ErrorCode.TOKEN_INVALID]: {
|
||||||
category: "authentication",
|
category: "authentication",
|
||||||
@ -378,68 +383,8 @@ export function canRetryError(code: string): boolean {
|
|||||||
return getErrorMetadata(code).shouldRetry;
|
return getErrorMetadata(code).shouldRetry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// NOTE: We intentionally do NOT support matching error messages to codes.
|
||||||
// Pattern Matching for Error Classification
|
// Error codes must be explicit and stable (returned from the API or thrown by server code).
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
interface ErrorPattern {
|
|
||||||
pattern: RegExp;
|
|
||||||
code: ErrorCodeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patterns to match error messages to error codes.
|
|
||||||
* Used when explicit error codes are not available.
|
|
||||||
*/
|
|
||||||
export const ErrorPatterns: ErrorPattern[] = [
|
|
||||||
// Authentication patterns
|
|
||||||
{ pattern: /invalid.*credentials?|wrong.*password|invalid.*password/i, code: ErrorCode.INVALID_CREDENTIALS },
|
|
||||||
{ pattern: /account.*locked|locked.*account|too.*many.*attempts/i, code: ErrorCode.ACCOUNT_LOCKED },
|
|
||||||
{ pattern: /session.*expired|expired.*session/i, code: ErrorCode.SESSION_EXPIRED },
|
|
||||||
{ pattern: /token.*expired|expired.*token/i, code: ErrorCode.SESSION_EXPIRED },
|
|
||||||
{ pattern: /token.*revoked|revoked.*token/i, code: ErrorCode.TOKEN_REVOKED },
|
|
||||||
{ pattern: /invalid.*token|token.*invalid/i, code: ErrorCode.TOKEN_INVALID },
|
|
||||||
{ pattern: /refresh.*token.*invalid|invalid.*refresh/i, code: ErrorCode.REFRESH_TOKEN_INVALID },
|
|
||||||
|
|
||||||
// Authorization patterns
|
|
||||||
{ pattern: /admin.*required|requires?.*admin/i, code: ErrorCode.ADMIN_REQUIRED },
|
|
||||||
{ pattern: /forbidden|not.*authorized|unauthorized/i, code: ErrorCode.FORBIDDEN },
|
|
||||||
{ pattern: /access.*denied|permission.*denied/i, code: ErrorCode.RESOURCE_ACCESS_DENIED },
|
|
||||||
|
|
||||||
// Business patterns
|
|
||||||
{ pattern: /already.*exists|email.*exists|account.*exists/i, code: ErrorCode.ACCOUNT_EXISTS },
|
|
||||||
{ pattern: /already.*linked/i, code: ErrorCode.ACCOUNT_ALREADY_LINKED },
|
|
||||||
{ pattern: /customer.*not.*found|account.*not.*found/i, code: ErrorCode.CUSTOMER_NOT_FOUND },
|
|
||||||
{ pattern: /already.*processed/i, code: ErrorCode.ORDER_ALREADY_PROCESSED },
|
|
||||||
{ pattern: /insufficient.*balance/i, code: ErrorCode.INSUFFICIENT_BALANCE },
|
|
||||||
|
|
||||||
// System patterns
|
|
||||||
{ pattern: /database|sql|postgres|prisma|connection.*refused/i, code: ErrorCode.DATABASE_ERROR },
|
|
||||||
{ pattern: /whmcs|salesforce|external.*service/i, code: ErrorCode.EXTERNAL_SERVICE_ERROR },
|
|
||||||
{ pattern: /configuration.*error|missing.*config/i, code: ErrorCode.CONFIGURATION_ERROR },
|
|
||||||
|
|
||||||
// Network patterns
|
|
||||||
{ pattern: /network.*error|fetch.*failed|econnrefused/i, code: ErrorCode.NETWORK_ERROR },
|
|
||||||
{ pattern: /timeout|timed?\s*out/i, code: ErrorCode.TIMEOUT },
|
|
||||||
{ pattern: /too.*many.*requests|rate.*limit/i, code: ErrorCode.RATE_LIMITED },
|
|
||||||
|
|
||||||
// Validation patterns (lower priority - checked last)
|
|
||||||
{ pattern: /not.*found/i, code: ErrorCode.NOT_FOUND },
|
|
||||||
{ pattern: /validation.*failed|invalid/i, code: ErrorCode.VALIDATION_FAILED },
|
|
||||||
{ pattern: /required|missing/i, code: ErrorCode.REQUIRED_FIELD_MISSING },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match an error message to an error code using patterns
|
|
||||||
*/
|
|
||||||
export function matchErrorPattern(message: string): ErrorCodeType {
|
|
||||||
for (const { pattern, code } of ErrorPatterns) {
|
|
||||||
if (pattern.test(message)) {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ErrorCode.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Zod Schema for Error Response
|
// Zod Schema for Error Response
|
||||||
@ -476,4 +421,3 @@ export function createErrorResponse(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user