mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
refactor(core): standardize frontend error handling (#10667)
This commit is contained in:
3
packages/common/error/README.md
Normal file
3
packages/common/error/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @affine/error
|
||||
|
||||
AFFiNE error handler utilities
|
||||
15
packages/common/error/package.json
Normal file
15
packages/common/error/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
102
packages/common/error/src/__tests__/index.spec.ts
Normal file
102
packages/common/error/src/__tests__/index.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
105
packages/common/error/src/index.ts
Normal file
105
packages/common/error/src/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
packages/common/error/tsconfig.json
Normal file
10
packages/common/error/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -8,5 +8,6 @@ export {
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from './ops';
|
||||
export { useEnsureLiveData, useLiveData } from './react';
|
||||
|
||||
@@ -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`.
|
||||
*
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"references": [{ "path": "../debug" }, { "path": "../env" }]
|
||||
"references": [
|
||||
{ "path": "../debug" },
|
||||
{ "path": "../env" },
|
||||
{ "path": "../error" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user