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";
|
||||||
@@ -5,10 +5,8 @@
|
|||||||
* 集中到这里。HTTP 拦截器(`http_client.ts`)已经对非 2xx 响应抛 `ApiError`;
|
* 集中到这里。HTTP 拦截器(`http_client.ts`)已经对非 2xx 响应抛 `ApiError`;
|
||||||
* 本工具处理第二种失败:2xx 但 `success: false` 的 envelope。
|
* 本工具处理第二种失败:2xx 但 `success: false` 的 envelope。
|
||||||
*
|
*
|
||||||
* 行为升级:失败时抛 `ApiError`(`ApiError extends Error`),而不是裸 `Error`。
|
* 失败时仍抛 `ApiError`,让底层保留 HTTP/API 上下文;进入仓库层 `Result`
|
||||||
* 这样调用方在 `Result.err` 内能 `instanceof ApiError` 检查 `.code` / `.status`。
|
* 后会被 `ExceptionHandler` 统一收敛为应用可消费的 `{ code, message }`。
|
||||||
*
|
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
import { ApiError, ErrorCode } from "./api_result";
|
import { ApiError, ErrorCode } from "./api_result";
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { setup, assign } from "xstate";
|
import { setup, assign } from "xstate";
|
||||||
|
|
||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import type { AuthProvider } from "@/lib/auth/auth_platform";
|
import type { AuthProvider } from "@/lib/auth/auth_platform";
|
||||||
|
|
||||||
import { AuthState, initialState } from "./auth-state";
|
import { AuthState, initialState } from "./auth-state";
|
||||||
@@ -23,6 +24,9 @@ export type { AuthState } from "./auth-state";
|
|||||||
export { initialState } from "./auth-state";
|
export { initialState } from "./auth-state";
|
||||||
export type { AuthEvent } from "./auth-events";
|
export type { AuthEvent } from "./auth-events";
|
||||||
|
|
||||||
|
const toAuthErrorMessage = (error: unknown): string =>
|
||||||
|
ExceptionHandler.message(error);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Machine
|
// Machine
|
||||||
//
|
//
|
||||||
@@ -119,8 +123,7 @@ export const authMachine = setup({
|
|||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: ({ event }) =>
|
errorMessage: ({ event }) => toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -145,8 +148,7 @@ export const authMachine = setup({
|
|||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: ({ event }) =>
|
errorMessage: ({ event }) => toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -174,8 +176,7 @@ export const authMachine = setup({
|
|||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: ({ event }) =>
|
errorMessage: ({ event }) => toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -208,8 +209,7 @@ export const authMachine = setup({
|
|||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: ({ event }) =>
|
errorMessage: ({ event }) => toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -231,8 +231,7 @@ export const authMachine = setup({
|
|||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: ({ event }) =>
|
errorMessage: ({ event }) => toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -254,8 +253,7 @@ export const authMachine = setup({
|
|||||||
actions: assign(({ event }) => ({
|
actions: assign(({ event }) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
errorMessage:
|
errorMessage: toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -281,8 +279,7 @@ export const authMachine = setup({
|
|||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: ({ event }) =>
|
errorMessage: ({ event }) => toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -309,8 +306,7 @@ export const authMachine = setup({
|
|||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: ({ event }) =>
|
errorMessage: ({ event }) => toAuthErrorMessage(event.error),
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fromCallback, fromPromise } from "xstate";
|
import { fromCallback, fromPromise } from "xstate";
|
||||||
|
|
||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import { MessageQueue } from "@/core/net/message-queue";
|
import { MessageQueue } from "@/core/net/message-queue";
|
||||||
import type { ChatSendResponse } from "@/data/dto/chat";
|
import type { ChatSendResponse } from "@/data/dto/chat";
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
@@ -131,8 +132,7 @@ function createMessageQueueActor(
|
|||||||
const output = await sendMessageViaHttp(content);
|
const output = await sendMessageViaHttp(content);
|
||||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage = ExceptionHandler.message(error);
|
||||||
error instanceof Error ? error.message : "Message send failed";
|
|
||||||
log.error("[chat-machine] message queue send failed", {
|
log.error("[chat-machine] message queue send failed", {
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import { PaymentPlan, type PaymentPlansResponse } from "@/data/dto/payment";
|
import { PaymentPlan, type PaymentPlansResponse } from "@/data/dto/payment";
|
||||||
|
|
||||||
import type { PaymentState } from "./payment-state";
|
import type { PaymentState } from "./payment-state";
|
||||||
@@ -7,7 +8,7 @@ export const PAYMENT_TIMEOUT_ERROR_MESSAGE =
|
|||||||
"Payment timed out. Please try again.";
|
"Payment timed out. Please try again.";
|
||||||
|
|
||||||
export function toPaymentErrorMessage(error: unknown): string {
|
export function toPaymentErrorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return ExceptionHandler.message(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasOrderPollingTimedOut(
|
export function hasOrderPollingTimedOut(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { AppException, ExceptionCode } from "@/core/errors";
|
||||||
|
|
||||||
|
import { Result, toError } from "../result";
|
||||||
|
|
||||||
|
describe("Result error normalization", () => {
|
||||||
|
it("normalizes unknown thrown values to AppException", async () => {
|
||||||
|
const result = await Result.wrap(async () => {
|
||||||
|
throw "boom";
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Result.isErr(result)).toBe(true);
|
||||||
|
if (Result.isOk(result)) throw new Error("Expected Result.err");
|
||||||
|
|
||||||
|
expect(result.error).toBeInstanceOf(AppException);
|
||||||
|
expect(result.error.message).toBe("boom");
|
||||||
|
expect((result.error as AppException).code).toBe(ExceptionCode.unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns AppException from toError", () => {
|
||||||
|
const error = toError(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect(error.message).toBe("Network connection failed.");
|
||||||
|
expect((error as AppException).code).toBe(ExceptionCode.network);
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-18
@@ -10,10 +10,13 @@
|
|||||||
* 如需在同模块中混用,可用 `import { Result as StorageResult } from
|
* 如需在同模块中混用,可用 `import { Result as StorageResult } from
|
||||||
* "@/data/storage/result"` 别名。
|
* "@/data/storage/result"` 别名。
|
||||||
*
|
*
|
||||||
* `error` 固定为 `Error`(非 `unknown`):HTTP 拦截器与共享 unwrap 都抛 `ApiError`
|
* `error` 仍固定为 `Error`(非 `unknown`):仓库 `Result.wrap` 捕获后用
|
||||||
* (`ApiError extends Error`),仓库 `Result.wrap` 捕获后用 `toError` 规范化,
|
* `ExceptionHandler` 统一归一化为 `AppException`,调用方可以稳定读取
|
||||||
* 调用方能可靠地 `instanceof ApiError` / `error.message` 访问错误信息。
|
* `error.message`;需要结构化错误时可继续通过 `ExceptionHandler.handle(error)`
|
||||||
|
* 得到简化后的 `{ code, message }`。
|
||||||
*/
|
*/
|
||||||
|
import { AppException, ExceptionHandler } from "@/core/errors";
|
||||||
|
|
||||||
import { Logger } from "./logger";
|
import { Logger } from "./logger";
|
||||||
|
|
||||||
export type Result<T> =
|
export type Result<T> =
|
||||||
@@ -30,7 +33,7 @@ export const Result = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造失败结果。`error` 接受任意 thrown 值,内部用 `toError` 归一化为 `Error`。
|
* 构造失败结果。`error` 接受任意 thrown 值,内部用 `toError` 归一化为 `AppException`。
|
||||||
*/
|
*/
|
||||||
err<T = never>(error: unknown): Result<T> {
|
err<T = never>(error: unknown): Result<T> {
|
||||||
return { success: false, error: toError(error) };
|
return { success: false, error: toError(error) };
|
||||||
@@ -56,11 +59,11 @@ export const Result = {
|
|||||||
* `*Api` 调用从「throw 风格」桥接到「Result 风格」。
|
* `*Api` 调用从「throw 风格」桥接到「Result 风格」。
|
||||||
*
|
*
|
||||||
* 错误处理策略(重点):
|
* 错误处理策略(重点):
|
||||||
* 1. 捕获后用 `toError` 规范化(保留 .stack / .code / .status 等字段)
|
* 1. 捕获后用 `toError` 规范化为 `AppException`,原始 thrown 值放入 `cause`
|
||||||
* 2. 用 Logger 记录(带 stack + component=Result 标签)—— 调试 dev 控制台可见,
|
* 2. 用 Logger 记录(带 stack + component=Result 标签)—— 调试 dev 控制台可见,
|
||||||
* 生产环境走 JSON pipeline 接入日志平台
|
* 生产环境走 JSON pipeline 接入日志平台
|
||||||
* 3. 不重新抛出 —— 调用方拿到的永远是 `Result<T>`(throw 风格调用方可在
|
* 3. 不重新抛出 —— 调用方拿到的永远是 `Result<T>`(throw 风格调用方可在
|
||||||
* 最外层显式 `throw Result.unwrap(r)`)
|
* 最外层显式 `throw r.error`)
|
||||||
*/
|
*/
|
||||||
async wrap<T>(thunk: () => Promise<T>): Promise<Result<T>> {
|
async wrap<T>(thunk: () => Promise<T>): Promise<Result<T>> {
|
||||||
try {
|
try {
|
||||||
@@ -77,17 +80,11 @@ export const Result = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把任意 thrown 值规范化为 `Error`:
|
* 把任意 thrown 值规范化为 `AppException`:
|
||||||
* - 已是 `Error` 子类 → 原样返回(保留 `.code` / `.status` 等自定义字段)
|
* - 已是 `AppException` → 原样返回
|
||||||
* - string → `new Error(str)`
|
* - 其他错误 → 交给 `ExceptionHandler` 收敛为 `{ code, message }`
|
||||||
* - 其他 → `new Error(JSON.stringify(value))`,失败则 `new Error(String(value))`
|
* - 原始值会保存在 `cause`,方便日志和调试继续追溯
|
||||||
*/
|
*/
|
||||||
export function toError(value: unknown): Error {
|
export function toError(value: unknown): AppException {
|
||||||
if (value instanceof Error) return value;
|
return ExceptionHandler.toError(value);
|
||||||
if (typeof value === "string") return new Error(value);
|
|
||||||
try {
|
|
||||||
return new Error(JSON.stringify(value));
|
|
||||||
} catch {
|
|
||||||
return new Error(String(value));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class SessionAsyncUtil {
|
|||||||
const json = typeof value === "string" ? JSON.parse(value) : value;
|
const json = typeof value === "string" ? JSON.parse(value) : value;
|
||||||
return Result.ok(schema.parse(json));
|
return Result.ok(schema.parse(json));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export class SessionAsyncUtil {
|
|||||||
await SessionAsyncUtil._storage.setItem(key, JSON.stringify(validated));
|
await SessionAsyncUtil._storage.setItem(key, JSON.stringify(validated));
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +59,11 @@ export class SessionAsyncUtil {
|
|||||||
await SessionAsyncUtil._storage.removeItem(key);
|
await SessionAsyncUtil._storage.removeItem(key);
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toError(e: unknown): Error {
|
|
||||||
return e instanceof Error ? e : new Error(String(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultSessionStorage(): Storage {
|
function createDefaultSessionStorage(): Storage {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return createStorage({ driver: memoryDriver() });
|
return createStorage({ driver: memoryDriver() });
|
||||||
|
|||||||
+15
-22
@@ -50,7 +50,7 @@ export class SpAsyncUtil {
|
|||||||
if (value.length === 0) return Result.ok(null);
|
if (value.length === 0) return Result.ok(null);
|
||||||
return Result.ok(value);
|
return Result.ok(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.setItem(key, value);
|
await SpAsyncUtil._storage.setItem(key, value);
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export class SpAsyncUtil {
|
|||||||
if (typeof value !== "number" || !Number.isFinite(value)) return Result.ok(null);
|
if (typeof value !== "number" || !Number.isFinite(value)) return Result.ok(null);
|
||||||
return Result.ok(Math.trunc(value));
|
return Result.ok(Math.trunc(value));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.setItem(key, value);
|
await SpAsyncUtil._storage.setItem(key, value);
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export class SpAsyncUtil {
|
|||||||
if (typeof value !== "number" || !Number.isFinite(value)) return Result.ok(null);
|
if (typeof value !== "number" || !Number.isFinite(value)) return Result.ok(null);
|
||||||
return Result.ok(value);
|
return Result.ok(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.setItem(key, value);
|
await SpAsyncUtil._storage.setItem(key, value);
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export class SpAsyncUtil {
|
|||||||
if (typeof value !== "boolean") return Result.ok(null);
|
if (typeof value !== "boolean") return Result.ok(null);
|
||||||
return Result.ok(value);
|
return Result.ok(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.setItem(key, value);
|
await SpAsyncUtil._storage.setItem(key, value);
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ export class SpAsyncUtil {
|
|||||||
if (!value.every((v) => typeof v === "string")) return Result.ok(null);
|
if (!value.every((v) => typeof v === "string")) return Result.ok(null);
|
||||||
return Result.ok(value);
|
return Result.ok(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.setItem(key, value);
|
await SpAsyncUtil._storage.setItem(key, value);
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export class SpAsyncUtil {
|
|||||||
const parsed = schema.parse(json);
|
const parsed = schema.parse(json);
|
||||||
return Result.ok(parsed);
|
return Result.ok(parsed);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.setItem(key, JSON.stringify(validated));
|
await SpAsyncUtil._storage.setItem(key, JSON.stringify(validated));
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.removeItem(key);
|
await SpAsyncUtil._storage.removeItem(key);
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ export class SpAsyncUtil {
|
|||||||
await SpAsyncUtil._storage.clear();
|
await SpAsyncUtil._storage.clear();
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,18 +208,11 @@ export class SpAsyncUtil {
|
|||||||
const exists = await SpAsyncUtil._storage.hasItem(key);
|
const exists = await SpAsyncUtil._storage.hasItem(key);
|
||||||
return Result.ok(exists);
|
return Result.ok(exists);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Result.err(toError(e));
|
return Result.err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 内部辅助
|
|
||||||
// ============================================================
|
|
||||||
function toError(e: unknown): Error {
|
|
||||||
return e instanceof Error ? e : new Error(String(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultStorage(): Storage {
|
function createDefaultStorage(): Storage {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return createStorage({ driver: memoryDriver() });
|
return createStorage({ driver: memoryDriver() });
|
||||||
|
|||||||
Reference in New Issue
Block a user