refactor(core): standardize frontend error handling (#10667)

This commit is contained in:
forehalo
2025-03-06 13:10:18 +00:00
parent 2e86bfffae
commit e02fb4fa94
70 changed files with 495 additions and 480 deletions

View File

@@ -0,0 +1,3 @@
# @affine/error
AFFiNE error handler utilities

View File

@@ -0,0 +1,15 @@
{
"name": "@affine/error",
"version": "0.20.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"@affine/graphql": "workspace:*",
"graphql": "^16.9.0"
},
"devDependencies": {
"vitest": "^3.0.7"
}
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { GraphQLError, UserFriendlyError } from '..';
describe('user friendly error', () => {
it('should create from graphql error', () => {
const gqlError = new GraphQLError('test', {
extensions: {
status: 400,
code: 'BAD_REQUEST',
type: 'BAD_REQUEST',
name: 'SOME_ERROR_NAME',
message: 'test',
},
});
const error = UserFriendlyError.fromAny(gqlError);
expect(error.name).toBe('SOME_ERROR_NAME');
expect(error.status).toBe(400);
expect(error.code).toBe('BAD_REQUEST');
expect(error.type).toBe('BAD_REQUEST');
expect(error.message).toBe('test');
});
it('should create from any error', () => {
const error = UserFriendlyError.fromAny(new Error('test'));
expect(error.message).toBe('test');
});
it('should create from object', () => {
const error = UserFriendlyError.fromAny({
name: 'SOME_ERROR_NAME',
status: 400,
code: 'BAD_REQUEST',
type: 'BAD_REQUEST',
message: 'test',
});
expect(error.message).toBe('test');
});
it('should create from string', () => {
const error = UserFriendlyError.fromAny('test error');
expect(error.message).toBe('test error');
});
it('should create fallback error', () => {
const error = UserFriendlyError.fromAny(null);
expect(error.message).toBe(
'Unhandled error raised. Please contact us for help.'
);
});
it('should test network error', () => {
const error = UserFriendlyError.fromAny({
name: 'NETWORK_ERROR',
status: 500,
code: 'INTERNAL_SERVER_ERROR',
type: 'INTERNAL_SERVER_ERROR',
message: 'test',
});
expect(error.isNetworkError()).toBe(true);
const error2 = UserFriendlyError.fromAny({
name: 'SOME_ERROR_NAME',
status: 400,
code: 'BAD_REQUEST',
type: 'BAD_REQUEST',
message: 'test',
});
expect(error2.isNetworkError()).toBe(false);
});
it('should test name', () => {
const error = UserFriendlyError.fromAny({
name: 'SOME_ERROR_NAME',
status: 400,
code: 'BAD_REQUEST',
type: 'BAD_REQUEST',
message: 'test',
});
// @ts-expect-error test name
expect(error.is('SOME_ERROR_NAME')).toBe(true);
});
it('should test status', () => {
const error = UserFriendlyError.fromAny({
name: 'SOME_ERROR_NAME',
status: 400,
code: 'BAD_REQUEST',
type: 'BAD_REQUEST',
message: 'test',
});
expect(error.isStatus(400)).toBe(true);
});
});

View File

@@ -0,0 +1,105 @@
import type { ErrorDataUnion, ErrorNames } from '@affine/graphql';
import { GraphQLError as BaseGraphQLError } from 'graphql';
export type ErrorName = keyof typeof ErrorNames | 'NETWORK_ERROR';
export interface UserFriendlyErrorResponse {
status: number;
code: string;
type: string;
name: ErrorName;
message: string;
data?: any;
stacktrace?: string;
}
function UnknownError(message: string) {
return new UserFriendlyError({
status: 500,
code: 'INTERNAL_SERVER_ERROR',
type: 'INTERNAL_SERVER_ERROR',
name: 'INTERNAL_SERVER_ERROR',
message,
});
}
type ToPascalCase<S extends string> = S extends `${infer A}_${infer B}`
? `${Capitalize<Lowercase<A>>}${ToPascalCase<B>}`
: Capitalize<Lowercase<S>>;
export type ErrorData = {
[K in ErrorNames]: Extract<
ErrorDataUnion,
{ __typename?: `${ToPascalCase<K>}DataType` }
>;
};
export class GraphQLError extends BaseGraphQLError {
// @ts-expect-error better to be a known type without any type casting
override extensions!: UserFriendlyErrorResponse;
}
export class UserFriendlyError
extends Error
implements UserFriendlyErrorResponse
{
readonly status = this.response.status;
readonly code = this.response.code;
readonly type = this.response.type;
override readonly name = this.response.name;
override readonly message = this.response.message;
readonly data = this.response.data;
readonly stacktrace = this.response.stacktrace;
static fromAny(anything: any) {
if (anything instanceof UserFriendlyError) {
return anything;
}
switch (typeof anything) {
case 'string':
return UnknownError(anything);
case 'object': {
if (anything) {
if (anything instanceof GraphQLError) {
return new UserFriendlyError(anything.extensions);
} else if (anything.type && anything.name && anything.message) {
return new UserFriendlyError(anything);
} else if (anything.message) {
return UnknownError(anything.message);
}
}
}
}
return UnknownError('Unhandled error raised. Please contact us for help.');
}
constructor(private readonly response: UserFriendlyErrorResponse) {
super(response.message);
}
is(name: ErrorName) {
return this.name === name;
}
isStatus(status: number) {
return this.status === status;
}
static isNetworkError(error: UserFriendlyError) {
return error.name === 'NETWORK_ERROR';
}
static notNetworkError(error: UserFriendlyError) {
return !UserFriendlyError.isNetworkError(error);
}
isNetworkError() {
return UserFriendlyError.isNetworkError(this);
}
notNetworkError() {
return UserFriendlyError.notNetworkError(this);
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.web.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": []
}

View File

@@ -13,6 +13,7 @@
"dependencies": {
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/error": "workspace:*",
"@affine/templates": "workspace:*",
"@datastructures-js/binary-search-tree": "^5.3.2",
"@preact/signals-core": "^1.8.0",

View File

@@ -8,5 +8,6 @@ export {
mapInto,
onComplete,
onStart,
smartRetry,
} from './ops';
export { useEnsureLiveData, useLiveData } from './react';

View File

@@ -1,3 +1,4 @@
import { UserFriendlyError } from '@affine/error';
import {
catchError,
connect,
@@ -144,6 +145,32 @@ export function backoffRetry<T>({
);
}
export function smartRetry<T>({
count = 3,
delay = 200,
maxDelay = 15000,
}: {
count?: number;
delay?: number;
maxDelay?: number;
} = {}) {
return (obs$: Observable<T>) =>
obs$.pipe(
backoffRetry({
when: UserFriendlyError.isNetworkError,
count: Infinity,
delay,
maxDelay,
}),
backoffRetry({
when: UserFriendlyError.notNetworkError,
count,
delay,
maxDelay,
})
);
}
/**
* An operator that combines `exhaustMap` and `switchMap`.
*

View File

@@ -6,5 +6,9 @@
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"references": [{ "path": "../debug" }, { "path": "../env" }]
"references": [
{ "path": "../debug" },
{ "path": "../env" },
{ "path": "../error" }
]
}