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";
}
}