feat(errors): add centralized exception handling

This commit is contained in:
2026-07-03 12:55:45 +08:00
parent e852fb62e9
commit 65d972fbb4
20 changed files with 492 additions and 70 deletions
@@ -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.",
});
});
});
+21
View File
@@ -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,
};
}
}
+16
View File
@@ -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];
+47
View File
@@ -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);
}
}
+6
View File
@@ -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.",
};
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./app-exception";
export * from "./exception-code";
export * from "./exception-handler";
export * from "./exception-result";