From 8a586e44718ba1914a92ea537add215b90eac742 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 3 Jul 2026 13:20:16 +0800 Subject: [PATCH] refactor(errors): classify auth and browser failures --- .../__tests__/exception-handler.test.ts | 16 +++++ src/core/errors/exception-code.ts | 2 + src/core/errors/exception-handler.ts | 5 ++ .../handlers/browser-exception-handler.ts | 50 ++++++++++++++++ .../interceptor/auth_refresh_interceptor.ts | 39 +++++++++++-- src/data/repositories/auth_repository.ts | 16 +++-- .../api/__tests__/http_client.test.ts | 58 +++++++++++++++++++ 7 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 src/core/errors/handlers/browser-exception-handler.ts diff --git a/src/core/errors/__tests__/exception-handler.test.ts b/src/core/errors/__tests__/exception-handler.test.ts index 1c77ddd0..2cb7eba2 100644 --- a/src/core/errors/__tests__/exception-handler.test.ts +++ b/src/core/errors/__tests__/exception-handler.test.ts @@ -78,6 +78,22 @@ describe("ExceptionHandler", () => { }); }); + it("maps browser capability and permission errors", () => { + expect(ExceptionHandler.handle(new Error("MediaRecorder not supported"))).toEqual({ + code: ExceptionCode.unsupported, + message: "This feature is not supported in this browser.", + }); + + expect( + ExceptionHandler.handle( + new DOMException("Microphone permission denied", "NotAllowedError"), + ), + ).toEqual({ + code: ExceptionCode.permissionDenied, + message: "Permission denied.", + }); + }); + it("preserves an existing AppException result", () => { const error = new AppException({ code: ExceptionCode.notFound, diff --git a/src/core/errors/exception-code.ts b/src/core/errors/exception-code.ts index c0bd732c..1ba23980 100644 --- a/src/core/errors/exception-code.ts +++ b/src/core/errors/exception-code.ts @@ -9,6 +9,8 @@ export const ExceptionCode = { validation: "VALIDATION_ERROR", storage: "STORAGE_ERROR", payment: "PAYMENT_ERROR", + permissionDenied: "PERMISSION_DENIED", + unsupported: "UNSUPPORTED_FEATURE", cancelled: "CANCELLED", } as const; diff --git a/src/core/errors/exception-handler.ts b/src/core/errors/exception-handler.ts index af25343d..40207c32 100644 --- a/src/core/errors/exception-handler.ts +++ b/src/core/errors/exception-handler.ts @@ -2,6 +2,7 @@ import { AppException } from "./app-exception"; import { ExceptionCode } from "./exception-code"; import type { ExceptionResult } from "./exception-result"; import { ApiExceptionHandler } from "./handlers/api-exception-handler"; +import { BrowserExceptionHandler } from "./handlers/browser-exception-handler"; import { DEFAULT_EXCEPTION_MESSAGE, FallbackExceptionHandler, @@ -25,6 +26,10 @@ export class ExceptionHandler { return NetworkExceptionHandler.handle(error); } + if (BrowserExceptionHandler.canHandle(error)) { + return BrowserExceptionHandler.handle(error); + } + if (ValidationExceptionHandler.canHandle(error)) { return ValidationExceptionHandler.handle(error); } diff --git a/src/core/errors/handlers/browser-exception-handler.ts b/src/core/errors/handlers/browser-exception-handler.ts new file mode 100644 index 00000000..1adea190 --- /dev/null +++ b/src/core/errors/handlers/browser-exception-handler.ts @@ -0,0 +1,50 @@ +import { ExceptionCode } from "../exception-code"; +import type { ExceptionResult } from "../exception-result"; + +export class BrowserExceptionHandler { + static canHandle(error: unknown): error is Error | DOMException { + if (!isErrorLike(error)) return false; + return isPermissionError(error) || isUnsupportedFeatureError(error); + } + + static handle(error: Error | DOMException): ExceptionResult { + if (isPermissionError(error)) { + return { + code: ExceptionCode.permissionDenied, + message: "Permission denied.", + }; + } + + return { + code: ExceptionCode.unsupported, + message: "This feature is not supported in this browser.", + }; + } +} + +function isPermissionError(error: Error | DOMException): boolean { + const name = error.name.toLowerCase(); + const message = error.message.toLowerCase(); + return ( + name === "notallowederror" || + name === "permissiondeniederror" || + message.includes("permission denied") || + (message.includes("permission") && message.includes("denied")) + ); +} + +function isUnsupportedFeatureError(error: Error | DOMException): boolean { + const name = error.name.toLowerCase(); + const message = error.message.toLowerCase(); + return ( + name === "notsupportederror" || + message.includes("not supported") || + message.includes("not available on server") || + message.includes("unsupported") + ); +} + +function isErrorLike(error: unknown): error is Error | DOMException { + if (error instanceof Error) return true; + return typeof DOMException !== "undefined" && error instanceof DOMException; +} diff --git a/src/core/net/interceptor/auth_refresh_interceptor.ts b/src/core/net/interceptor/auth_refresh_interceptor.ts index 57e752ce..9c40512e 100644 --- a/src/core/net/interceptor/auth_refresh_interceptor.ts +++ b/src/core/net/interceptor/auth_refresh_interceptor.ts @@ -69,7 +69,11 @@ async function refreshLoginToken(baseUrl: string): Promise { const storage = AuthStorage.getInstance(); const refreshResult = await storage.getRefreshToken(); if (!Result.isOk(refreshResult) || !refreshResult.data) { - throw new Error("No refresh token available"); + throw new ApiError( + "HTTP_UNAUTHORIZED", + "No refresh token available", + ErrorCode.httpUnauthorized, + ); } const response = await fetch(`${baseUrl}${ApiPath.refresh}`, { @@ -79,7 +83,12 @@ async function refreshLoginToken(baseUrl: string): Promise { }); if (!response.ok) { - throw new Error(`Refresh failed: ${response.status}`); + throw new ApiError( + apiCodeForStatus(response.status), + "Refresh failed", + response.status, + { statusText: response.statusText }, + ); } const json = (await response.json()) as { @@ -91,7 +100,7 @@ async function refreshLoginToken(baseUrl: string): Promise { if (json.data.token) await storage.setLoginToken(json.data.token); if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken); } else { - throw new Error("Refresh response invalid"); + throw new ApiError(ErrorCode.parseError, "Refresh response invalid"); } } @@ -109,7 +118,12 @@ async function refreshGuestToken(baseUrl: string): Promise { }); if (!response.ok) { - throw new Error(`Guest refresh failed: ${response.status}`); + throw new ApiError( + apiCodeForStatus(response.status), + "Guest refresh failed", + response.status, + { statusText: response.statusText }, + ); } const json = (await response.json()) as { @@ -121,7 +135,22 @@ async function refreshGuestToken(baseUrl: string): Promise { if (json.data.token) await storage.setGuestToken(json.data.token); if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId); } else { - throw new Error("Guest refresh response invalid"); + throw new ApiError(ErrorCode.parseError, "Guest refresh response invalid"); + } +} + +function apiCodeForStatus(status: number): string { + switch (status) { + case ErrorCode.httpUnauthorized: + return "HTTP_UNAUTHORIZED"; + case ErrorCode.httpForbidden: + return "HTTP_FORBIDDEN"; + case ErrorCode.httpNotFound: + return "HTTP_NOT_FOUND"; + case ErrorCode.httpServerError: + return "HTTP_SERVER_ERROR"; + default: + return "HTTP_ERROR"; } } diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index e33dfc6e..76347c48 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -1,6 +1,7 @@ "use client"; -import { AuthApi, authApi } from "@/data/services/api"; +import { ExceptionHandler } from "@/core/errors"; +import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api"; import { AppleLoginRequest, FacebookLoginRequest, @@ -141,9 +142,10 @@ export class AuthRepository implements IAuthRepository { } return response; }).catch((e) => { + const errorResult = ExceptionHandler.handle(e); log.error("[AuthRepository.guestLogin] API call FAILED", { - errorName: e?.name, - errorMessage: e?.message, + errorCode: errorResult.code, + errorMessage: errorResult.message, }); return Result.err(e); }); @@ -227,7 +229,13 @@ export class AuthRepository implements IAuthRepository { async refreshToken(): Promise> { const existing = await this.storage.getRefreshToken(); if (!existing.success || !existing.data) { - return Result.err(new Error("No refresh token available")); + return Result.err( + new ApiError( + "HTTP_UNAUTHORIZED", + "No refresh token available", + ErrorCode.httpUnauthorized, + ), + ); } const refreshToken = existing.data; return Result.wrap(async () => { diff --git a/src/data/services/api/__tests__/http_client.test.ts b/src/data/services/api/__tests__/http_client.test.ts index 4c415e9e..7a95d580 100644 --- a/src/data/services/api/__tests__/http_client.test.ts +++ b/src/data/services/api/__tests__/http_client.test.ts @@ -5,6 +5,7 @@ import memoryDriver from "unstorage/drivers/memory"; import { AuthStorage } from "@/data/storage/auth"; import { SpAsyncUtil } from "@/utils"; +import { ErrorCode } from "../api_result"; import { createHttpClient } from "../http_client"; function jsonResponse(body: unknown, init?: ResponseInit): Response { @@ -86,4 +87,61 @@ describe("createHttpClient", () => { "Bearer new-login-token", ]); }); + + it("rejects with an API unauthorized error when no refresh token exists", async () => { + const authStorage = AuthStorage.getInstance(); + await authStorage.setLoginToken("old-login-token"); + + const fetchMock = vi.fn(async () => + jsonResponse( + { message: "expired" }, + { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const http = createHttpClient(); + await expect(http("/api/chat/history")).rejects.toMatchObject({ + name: "ApiError", + code: "HTTP_UNAUTHORIZED", + status: ErrorCode.httpUnauthorized, + message: "No refresh token available", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects with API status context when token refresh fails", async () => { + const authStorage = AuthStorage.getInstance(); + await authStorage.setLoginToken("old-login-token"); + await authStorage.setRefreshToken("refresh-token"); + + const fetchMock = vi.fn(async (input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname === "/api/auth/refresh") { + return jsonResponse( + { message: "refresh broken" }, + { status: ErrorCode.httpServerError, statusText: "Server Error" }, + ); + } + return jsonResponse( + { message: "expired" }, + { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const http = createHttpClient(); + await expect(http("/api/chat/history")).rejects.toMatchObject({ + name: "ApiError", + code: "HTTP_SERVER_ERROR", + status: ErrorCode.httpServerError, + message: "Refresh failed", + }); + + expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ + "/api/chat/history", + "/api/auth/refresh", + ]); + }); });