feat(errors): add centralized exception handling
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ApiError, ErrorCode } from "@/data/services/api/api_result";
|
||||
|
||||
import { AppException } from "../app-exception";
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import { ExceptionHandler } from "../exception-handler";
|
||||
|
||||
describe("ExceptionHandler", () => {
|
||||
it("maps API status errors to stable user messages", () => {
|
||||
expect(
|
||||
ExceptionHandler.handle(
|
||||
new ApiError("HTTP_UNAUTHORIZED", "raw backend message", 401),
|
||||
),
|
||||
).toEqual({
|
||||
code: ExceptionCode.unauthorized,
|
||||
message: "Session expired, please sign in again.",
|
||||
});
|
||||
|
||||
expect(
|
||||
ExceptionHandler.handle(
|
||||
new ApiError("HTTP_SERVER_ERROR", "internal", 500),
|
||||
),
|
||||
).toEqual({
|
||||
code: ExceptionCode.server,
|
||||
message: "Server error, please try again later.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps known API codes without status", () => {
|
||||
expect(
|
||||
ExceptionHandler.handle(
|
||||
new ApiError(ErrorCode.timeoutError, "request timeout"),
|
||||
),
|
||||
).toEqual({
|
||||
code: ExceptionCode.timeout,
|
||||
message: "request timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps network and cancellation errors", () => {
|
||||
expect(ExceptionHandler.handle(new TypeError("Failed to fetch"))).toEqual({
|
||||
code: ExceptionCode.network,
|
||||
message: "Network connection failed.",
|
||||
});
|
||||
|
||||
expect(ExceptionHandler.handle(new DOMException("Aborted", "AbortError"))).toEqual({
|
||||
code: ExceptionCode.cancelled,
|
||||
message: "Request cancelled.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps schema and syntax errors to validation errors", () => {
|
||||
const parseResult = z.object({ name: z.string() }).safeParse({ name: null });
|
||||
if (parseResult.success) throw new Error("Expected parse failure");
|
||||
|
||||
expect(ExceptionHandler.handle(parseResult.error)).toEqual({
|
||||
code: ExceptionCode.validation,
|
||||
message: "Data format changed unexpectedly. Please try again.",
|
||||
});
|
||||
|
||||
expect(ExceptionHandler.handle(new SyntaxError("Unexpected token"))).toEqual({
|
||||
code: ExceptionCode.validation,
|
||||
message: "Data format changed unexpectedly. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps storage and payment errors", () => {
|
||||
expect(ExceptionHandler.handle(new Error("localStorage unavailable"))).toEqual({
|
||||
code: ExceptionCode.storage,
|
||||
message: "Local data could not be saved. Please try again.",
|
||||
});
|
||||
|
||||
expect(ExceptionHandler.handle(new Error("Stripe payment failed"))).toEqual({
|
||||
code: ExceptionCode.payment,
|
||||
message: "Payment could not be completed. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an existing AppException result", () => {
|
||||
const error = new AppException({
|
||||
code: ExceptionCode.notFound,
|
||||
message: "Custom not found",
|
||||
});
|
||||
|
||||
expect(ExceptionHandler.handle(error)).toEqual({
|
||||
code: ExceptionCode.notFound,
|
||||
message: "Custom not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a simple message", () => {
|
||||
expect(ExceptionHandler.handle("plain failure")).toEqual({
|
||||
code: ExceptionCode.unknown,
|
||||
message: "plain failure",
|
||||
});
|
||||
|
||||
expect(ExceptionHandler.handle({ nope: true })).toEqual({
|
||||
code: ExceptionCode.unknown,
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ExceptionResult } from "./exception-result";
|
||||
import type { ExceptionCode } from "./exception-code";
|
||||
|
||||
export class AppException extends Error {
|
||||
readonly code: ExceptionCode;
|
||||
override readonly cause?: unknown;
|
||||
|
||||
constructor(result: ExceptionResult, cause?: unknown) {
|
||||
super(result.message);
|
||||
this.name = "AppException";
|
||||
this.code = result.code;
|
||||
this.cause = cause;
|
||||
}
|
||||
|
||||
toResult(): ExceptionResult {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const ExceptionCode = {
|
||||
unknown: "UNKNOWN",
|
||||
network: "NETWORK_ERROR",
|
||||
timeout: "NETWORK_TIMEOUT",
|
||||
unauthorized: "HTTP_UNAUTHORIZED",
|
||||
forbidden: "HTTP_FORBIDDEN",
|
||||
notFound: "HTTP_NOT_FOUND",
|
||||
server: "HTTP_SERVER_ERROR",
|
||||
validation: "VALIDATION_ERROR",
|
||||
storage: "STORAGE_ERROR",
|
||||
payment: "PAYMENT_ERROR",
|
||||
cancelled: "CANCELLED",
|
||||
} as const;
|
||||
|
||||
export type ExceptionCode =
|
||||
(typeof ExceptionCode)[keyof typeof ExceptionCode];
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AppException } from "./app-exception";
|
||||
import type { ExceptionResult } from "./exception-result";
|
||||
import { ApiExceptionHandler } from "./handlers/api-exception-handler";
|
||||
import { FallbackExceptionHandler } from "./handlers/fallback-exception-handler";
|
||||
import { NetworkExceptionHandler } from "./handlers/network-exception-handler";
|
||||
import { PaymentExceptionHandler } from "./handlers/payment-exception-handler";
|
||||
import { StorageExceptionHandler } from "./handlers/storage-exception-handler";
|
||||
import { ValidationExceptionHandler } from "./handlers/validation-exception-handler";
|
||||
|
||||
export class ExceptionHandler {
|
||||
static handle(error: unknown): ExceptionResult {
|
||||
if (error instanceof AppException) {
|
||||
return error.toResult();
|
||||
}
|
||||
|
||||
if (ApiExceptionHandler.canHandle(error)) {
|
||||
return ApiExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
if (NetworkExceptionHandler.canHandle(error)) {
|
||||
return NetworkExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
if (ValidationExceptionHandler.canHandle(error)) {
|
||||
return ValidationExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
if (StorageExceptionHandler.canHandle(error)) {
|
||||
return StorageExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
if (PaymentExceptionHandler.canHandle(error)) {
|
||||
return PaymentExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
return FallbackExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
static message(error: unknown): string {
|
||||
return ExceptionHandler.handle(error).message;
|
||||
}
|
||||
|
||||
static toError(error: unknown): AppException {
|
||||
if (error instanceof AppException) return error;
|
||||
return new AppException(ExceptionHandler.handle(error), error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ExceptionCode } from "./exception-code";
|
||||
|
||||
export interface ExceptionResult {
|
||||
code: ExceptionCode;
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiError, ErrorCode } from "@/data/services/api/api_result";
|
||||
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import type { ExceptionResult } from "../exception-result";
|
||||
|
||||
export class ApiExceptionHandler {
|
||||
static canHandle(error: unknown): error is ApiError {
|
||||
return error instanceof ApiError;
|
||||
}
|
||||
|
||||
static handle(error: ApiError): ExceptionResult {
|
||||
switch (error.status) {
|
||||
case ErrorCode.httpUnauthorized:
|
||||
return {
|
||||
code: ExceptionCode.unauthorized,
|
||||
message: "Session expired, please sign in again.",
|
||||
};
|
||||
case ErrorCode.httpForbidden:
|
||||
return {
|
||||
code: ExceptionCode.forbidden,
|
||||
message: "Access denied.",
|
||||
};
|
||||
case ErrorCode.httpNotFound:
|
||||
return {
|
||||
code: ExceptionCode.notFound,
|
||||
message: "Resource not found.",
|
||||
};
|
||||
case ErrorCode.httpServerError:
|
||||
return {
|
||||
code: ExceptionCode.server,
|
||||
message: "Server error, please try again later.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: mapApiCode(error.code),
|
||||
message: error.message || "API call failed.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapApiCode(code: string): ExceptionResult["code"] {
|
||||
switch (code) {
|
||||
case "HTTP_UNAUTHORIZED":
|
||||
return ExceptionCode.unauthorized;
|
||||
case "HTTP_SERVER_ERROR":
|
||||
return ExceptionCode.server;
|
||||
case ErrorCode.networkError:
|
||||
return ExceptionCode.network;
|
||||
case ErrorCode.timeoutError:
|
||||
return ExceptionCode.timeout;
|
||||
case ErrorCode.parseError:
|
||||
return ExceptionCode.validation;
|
||||
default:
|
||||
return ExceptionCode.unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import type { ExceptionResult } from "../exception-result";
|
||||
|
||||
export class FallbackExceptionHandler {
|
||||
static handle(error: unknown): ExceptionResult {
|
||||
return {
|
||||
code: ExceptionCode.unknown,
|
||||
message: resolveMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === "string" && error.trim().length > 0) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import type { ExceptionResult } from "../exception-result";
|
||||
|
||||
export class NetworkExceptionHandler {
|
||||
static canHandle(error: unknown): error is Error | DOMException {
|
||||
if (!isErrorLike(error)) return false;
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
error.name === "AbortError" ||
|
||||
error.name === "TimeoutError" ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("fetch failed") ||
|
||||
message.includes("network") ||
|
||||
message.includes("timeout")
|
||||
);
|
||||
}
|
||||
|
||||
static handle(error: Error | DOMException): ExceptionResult {
|
||||
if (error.name === "AbortError") {
|
||||
return {
|
||||
code: ExceptionCode.cancelled,
|
||||
message: "Request cancelled.",
|
||||
};
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
if (error.name === "TimeoutError" || message.includes("timeout")) {
|
||||
return {
|
||||
code: ExceptionCode.timeout,
|
||||
message: "Request timeout, please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: ExceptionCode.network,
|
||||
message: "Network connection failed.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorLike(error: unknown): error is Error | DOMException {
|
||||
if (error instanceof Error) return true;
|
||||
return typeof DOMException !== "undefined" && error instanceof DOMException;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import type { ExceptionResult } from "../exception-result";
|
||||
|
||||
export class PaymentExceptionHandler {
|
||||
static canHandle(error: unknown): error is Error {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("payment") ||
|
||||
message.includes("stripe") ||
|
||||
message.includes("ezpay") ||
|
||||
message.includes("order")
|
||||
);
|
||||
}
|
||||
|
||||
static handle(error: Error): ExceptionResult {
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes("cancel")) {
|
||||
return {
|
||||
code: ExceptionCode.cancelled,
|
||||
message: "Payment was cancelled.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: ExceptionCode.payment,
|
||||
message: "Payment could not be completed. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import type { ExceptionResult } from "../exception-result";
|
||||
|
||||
export class StorageExceptionHandler {
|
||||
static canHandle(error: unknown): error is Error | DOMException {
|
||||
if (!isErrorLike(error)) return false;
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
error.name === "QuotaExceededError" ||
|
||||
error.name === "SecurityError" ||
|
||||
message.includes("localstorage") ||
|
||||
message.includes("indexeddb") ||
|
||||
message.includes("quota") ||
|
||||
message.includes("storage")
|
||||
);
|
||||
}
|
||||
|
||||
static handle(error: Error | DOMException): ExceptionResult {
|
||||
if (error.name === "QuotaExceededError") {
|
||||
return {
|
||||
code: ExceptionCode.storage,
|
||||
message: "Storage quota exceeded.",
|
||||
};
|
||||
}
|
||||
|
||||
if (error.name === "SecurityError") {
|
||||
return {
|
||||
code: ExceptionCode.storage,
|
||||
message: "Storage permission denied.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: ExceptionCode.storage,
|
||||
message: "Local data could not be saved. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorLike(error: unknown): error is Error | DOMException {
|
||||
if (error instanceof Error) return true;
|
||||
return typeof DOMException !== "undefined" && error instanceof DOMException;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import type { ExceptionResult } from "../exception-result";
|
||||
|
||||
export class ValidationExceptionHandler {
|
||||
static canHandle(error: unknown): error is z.ZodError | SyntaxError {
|
||||
return error instanceof z.ZodError || error instanceof SyntaxError;
|
||||
}
|
||||
|
||||
static handle(error?: z.ZodError | SyntaxError): ExceptionResult {
|
||||
void error;
|
||||
return {
|
||||
code: ExceptionCode.validation,
|
||||
message: "Data format changed unexpectedly. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./app-exception";
|
||||
export * from "./exception-code";
|
||||
export * from "./exception-handler";
|
||||
export * from "./exception-result";
|
||||
Reference in New Issue
Block a user