refactor(errors): classify auth and browser failures

This commit is contained in:
2026-07-03 13:20:16 +08:00
parent 8a71041b5d
commit 8a586e4471
7 changed files with 177 additions and 9 deletions
@@ -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,
+2
View File
@@ -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;
+5
View File
@@ -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);
}
@@ -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;
}
@@ -69,7 +69,11 @@ async function refreshLoginToken(baseUrl: string): Promise<void> {
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<void> {
});
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<void> {
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<void> {
});
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<void> {
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";
}
}
+12 -4
View File
@@ -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<Result<RefreshTokenResponse>> {
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 () => {
@@ -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<typeof fetch>(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<typeof fetch>(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",
]);
});
});