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

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

No files matched your search

@@ -5,6 +5,7 @@ import { HttpStatus, Logger } from '@nestjs/common';
import { ClsServiceManager } from 'nestjs-cls'; import { ClsServiceManager } from 'nestjs-cls';
export type UserFriendlyErrorBaseType = export type UserFriendlyErrorBaseType =
| 'network_error'
| 'bad_request' | 'bad_request'
| 'too_many_requests' | 'too_many_requests'
| 'resource_not_found' | 'resource_not_found'
@@ -26,6 +27,7 @@ export type UserFriendlyErrorOptions = {
}; };
const BaseTypeToHttpStatusMap: Record<UserFriendlyErrorBaseType, HttpStatus> = { const BaseTypeToHttpStatusMap: Record<UserFriendlyErrorBaseType, HttpStatus> = {
network_error: HttpStatus.GATEWAY_TIMEOUT,
too_many_requests: HttpStatus.TOO_MANY_REQUESTS, too_many_requests: HttpStatus.TOO_MANY_REQUESTS,
bad_request: HttpStatus.BAD_REQUEST, bad_request: HttpStatus.BAD_REQUEST,
resource_not_found: HttpStatus.NOT_FOUND, resource_not_found: HttpStatus.NOT_FOUND,
@@ -239,6 +241,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'internal_server_error', type: 'internal_server_error',
message: 'An internal error occurred.', message: 'An internal error occurred.',
}, },
network_error: {
type: 'network_error',
message: 'Network error.',
},
too_many_request: { too_many_request: {
type: 'too_many_requests', type: 'too_many_requests',
message: 'Too many requests.', message: 'Too many requests.',
@@ -10,6 +10,12 @@ export class InternalServerError extends UserFriendlyError {
} }
} }
export class NetworkError extends UserFriendlyError {
constructor(message?: string) {
super('network_error', 'network_error', message);
}
}
export class TooManyRequest extends UserFriendlyError { export class TooManyRequest extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('too_many_requests', 'too_many_request', message); super('too_many_requests', 'too_many_request', message);
@@ -851,6 +857,7 @@ export class UnsupportedClientVersion extends UserFriendlyError {
} }
export enum ErrorNames { export enum ErrorNames {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
NETWORK_ERROR,
TOO_MANY_REQUEST, TOO_MANY_REQUEST,
NOT_FOUND, NOT_FOUND,
BAD_REQUEST, BAD_REQUEST,
+3
View File
@@ -0,0 +1,3 @@
# @affine/error
AFFiNE error handler utilities
+15
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"
}
}
@@ -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
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);
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.web.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": []
}
+1
View File
@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@affine/debug": "workspace:*", "@affine/debug": "workspace:*",
"@affine/env": "workspace:*", "@affine/env": "workspace:*",
"@affine/error": "workspace:*",
"@affine/templates": "workspace:*", "@affine/templates": "workspace:*",
"@datastructures-js/binary-search-tree": "^5.3.2", "@datastructures-js/binary-search-tree": "^5.3.2",
"@preact/signals-core": "^1.8.0", "@preact/signals-core": "^1.8.0",
@@ -8,5 +8,6 @@ export {
mapInto, mapInto,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from './ops'; } from './ops';
export { useEnsureLiveData, useLiveData } from './react'; export { useEnsureLiveData, useLiveData } from './react';
+27
View File
@@ -1,3 +1,4 @@
import { UserFriendlyError } from '@affine/error';
import { import {
catchError, catchError,
connect, 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`. * An operator that combines `exhaustMap` and `switchMap`.
* *
+5 -1
View File
@@ -6,5 +6,9 @@
"outDir": "./dist", "outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
}, },
"references": [{ "path": "../debug" }, { "path": "../env" }] "references": [
{ "path": "../debug" },
{ "path": "../env" },
{ "path": "../error" }
]
} }
+1
View File
@@ -22,6 +22,7 @@
"dependencies": { "dependencies": {
"@affine/debug": "workspace:*", "@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*", "@affine/electron-api": "workspace:*",
"@affine/error": "workspace:*",
"@affine/graphql": "workspace:*", "@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*", "@affine/i18n": "workspace:*",
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
@@ -1,9 +1,6 @@
import { AuthPageContainer } from '@affine/component/auth-components'; import { AuthPageContainer } from '@affine/component/auth-components';
import { import { UserFriendlyError } from '@affine/error';
ErrorNames, import { ErrorNames, type GetInviteInfoQuery } from '@affine/graphql';
type GetInviteInfoQuery,
UserFriendlyError,
} from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { Avatar } from '../../ui/avatar'; import { Avatar } from '../../ui/avatar';
@@ -16,7 +13,7 @@ export const JoinFailedPage = ({
inviteInfo: GetInviteInfoQuery['getInviteInfo']; inviteInfo: GetInviteInfoQuery['getInviteInfo'];
error?: any; error?: any;
}) => { }) => {
const userFriendlyError = UserFriendlyError.fromAnyError(error); const userFriendlyError = UserFriendlyError.fromAny(error);
const t = useI18n(); const t = useI18n();
return ( return (
<AuthPageContainer <AuthPageContainer
@@ -14,6 +14,7 @@
"references": [ "references": [
{ "path": "../../common/debug" }, { "path": "../../common/debug" },
{ "path": "../electron-api" }, { "path": "../electron-api" },
{ "path": "../../common/error" },
{ "path": "../graphql" }, { "path": "../graphql" },
{ "path": "../i18n" }, { "path": "../i18n" },
{ "path": "../../../tools/utils" }, { "path": "../../../tools/utils" },
+1
View File
@@ -12,6 +12,7 @@
"@affine/debug": "workspace:*", "@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*", "@affine/electron-api": "workspace:*",
"@affine/env": "workspace:*", "@affine/env": "workspace:*",
"@affine/error": "workspace:*",
"@affine/graphql": "workspace:*", "@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*", "@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*", "@affine/nbstore": "workspace:*",
@@ -4,6 +4,7 @@ import {
UnauthorizedError, UnauthorizedError,
} from '@affine/core/blocksuite/ai/components/ai-item/types'; } from '@affine/core/blocksuite/ai/components/ai-item/types';
import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-login-required'; import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-login-required';
import type { UserFriendlyError } from '@affine/error';
import { import {
addContextDocMutation, addContextDocMutation,
cleanupCopilotSessionMutation, cleanupCopilotSessionMutation,
@@ -14,7 +15,6 @@ import {
getCopilotHistoriesQuery, getCopilotHistoriesQuery,
getCopilotHistoryIdsQuery, getCopilotHistoryIdsQuery,
getCopilotSessionsQuery, getCopilotSessionsQuery,
GraphQLError,
type GraphQLQuery, type GraphQLQuery,
listContextDocsAndFilesQuery, listContextDocsAndFilesQuery,
listContextQuery, listContextQuery,
@@ -23,7 +23,6 @@ import {
removeContextDocMutation, removeContextDocMutation,
type RequestOptions, type RequestOptions,
updateCopilotSessionMutation, updateCopilotSessionMutation,
UserFriendlyError,
} from '@affine/graphql'; } from '@affine/graphql';
import { getCurrentStore } from '@toeverything/infra'; import { getCurrentStore } from '@toeverything/infra';
@@ -40,18 +39,13 @@ function codeToError(error: UserFriendlyError) {
return new GeneralNetworkError( return new GeneralNetworkError(
error.code error.code
? `${error.code}: ${error.message}\nIdentify: ${error.name}` ? `${error.code}: ${error.message}\nIdentify: ${error.name}`
: undefined : error.message
); );
} }
} }
export function resolveError(err: any) { export function resolveError(err: any) {
const standardError = return codeToError(err);
err instanceof GraphQLError
? new UserFriendlyError(err.extensions)
: UserFriendlyError.fromAnyError(err);
return codeToError(standardError);
} }
export function handleError(src: any) { export function handleError(src: any) {
@@ -11,7 +11,7 @@ import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { OpenInAppService } from '@affine/core/modules/open-in-app'; import { OpenInAppService } from '@affine/core/modules/open-in-app';
import { GuardService } from '@affine/core/modules/permissions'; import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { import {
@@ -87,7 +87,7 @@ export function useRegisterBlocksuiteEditorCommands(
doc.moveToTrash(); doc.moveToTrash();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
const userFriendlyError = UserFriendlyError.fromAnyError(error); const userFriendlyError = UserFriendlyError.fromAny(error);
toast(t[`error.${userFriendlyError.name}`](userFriendlyError.data)); toast(t[`error.${userFriendlyError.name}`](userFriendlyError.data));
} }
}, },
@@ -1,5 +1,5 @@
import { notify } from '@affine/component'; import { notify } from '@affine/component';
import { GraphQLError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import type { PropsWithChildren, ReactNode } from 'react'; import type { PropsWithChildren, ReactNode } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { SWRConfiguration } from 'swr'; import type { SWRConfiguration } from 'swr';
@@ -17,21 +17,13 @@ const swrConfig: SWRConfiguration = {
const d = fetcher(...args); const d = fetcher(...args);
if (d instanceof Promise) { if (d instanceof Promise) {
return d.catch(e => { return d.catch(e => {
if ( const error = UserFriendlyError.fromAny(e);
e instanceof GraphQLError ||
(Array.isArray(e) && e[0] instanceof GraphQLError) notify.error({
) { title: error.name,
const graphQLError = e instanceof GraphQLError ? e : e[0]; message: error.message,
notify.error({ });
title: 'GraphQL Error',
message: graphQLError.toString(),
});
} else {
notify.error({
title: 'Error',
message: e.toString(),
});
}
throw e; throw e;
}); });
} }
@@ -9,7 +9,7 @@ import { DocsService } from '@affine/core/modules/doc';
import { GlobalContextService } from '@affine/core/modules/global-context'; import { GlobalContextService } from '@affine/core/modules/global-context';
import { GuardService } from '@affine/core/modules/permissions'; import { GuardService } from '@affine/core/modules/permissions';
import type { AffineDNDData } from '@affine/core/types/dnd'; import type { AffineDNDData } from '@affine/core/types/dnd';
import { UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
@@ -58,8 +58,7 @@ export const TrashButton = () => {
docRecord.moveToTrash(); docRecord.moveToTrash();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
const userFriendlyError = const userFriendlyError = UserFriendlyError.fromAny(error);
UserFriendlyError.fromAnyError(error);
toast( toast(
t[`error.${userFriendlyError.name}`](userFriendlyError.data) t[`error.${userFriendlyError.name}`](userFriendlyError.data)
); );
@@ -8,13 +8,10 @@ import {
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button'; import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
AuthService,
BackendError,
CaptchaService,
} from '@affine/core/modules/cloud';
import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session'; import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session';
import { Unreachable } from '@affine/env/constant'; import { Unreachable } from '@affine/env/constant';
import type { UserFriendlyError } from '@affine/error';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { import {
@@ -143,12 +140,10 @@ export const SignInWithEmailStep = ({
try { try {
await authService.signInMagicLink(email, otp, false); await authService.signInMagicLink(email, otp, false);
} catch (e) { } catch (e) {
if (e instanceof BackendError) { notify.error({
notify.error({ title: (e as UserFriendlyError).message,
title: e.originError.message, });
}); setOtpError(t['com.affine.auth.sign.auth.code.invalid']());
setOtpError(t['com.affine.auth.sign.auth.code.invalid']());
}
} finally { } finally {
setIsVerifying(false); setIsVerifying(false);
} }
@@ -15,6 +15,7 @@ import {
SubscriptionService, SubscriptionService,
} from '@affine/core/modules/cloud'; } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url'; import { UrlService } from '@affine/core/modules/url';
import { UserFriendlyError } from '@affine/error';
import type { InvoicesQuery } from '@affine/graphql'; import type { InvoicesQuery } from '@affine/graphql';
import { import {
createCustomerPortalMutation, createCustomerPortalMutation,
@@ -22,7 +23,6 @@ import {
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
SubscriptionStatus, SubscriptionStatus,
UserFriendlyError,
} from '@affine/graphql'; } from '@affine/graphql';
import { type I18nString, i18nTime, Trans, useI18n } from '@affine/i18n'; import { type I18nString, i18nTime, Trans, useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
@@ -539,8 +539,8 @@ const BillingHistory = () => {
return ( return (
<span style={{ color: cssVar('errorColor') }}> <span style={{ color: cssVar('errorColor') }}>
{error {error
? UserFriendlyError.fromAnyError(error).message ? UserFriendlyError.fromAny(error).message
: 'Failed to load members'} : 'Failed to load invoices'}
</span> </span>
); );
} }
@@ -17,13 +17,13 @@ import {
import { WorkspaceQuotaService } from '@affine/core/modules/quota'; import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { UrlService } from '@affine/core/modules/url'; import { UrlService } from '@affine/core/modules/url';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error';
import { import {
createCustomerPortalMutation, createCustomerPortalMutation,
type InvoicesQuery, type InvoicesQuery,
InvoiceStatus, InvoiceStatus,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
UserFriendlyError,
} from '@affine/graphql'; } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
@@ -321,8 +321,8 @@ const BillingHistory = () => {
return ( return (
<span style={{ color: cssVar('errorColor') }}> <span style={{ color: cssVar('errorColor') }}>
{error {error
? UserFriendlyError.fromAnyError(error).message ? UserFriendlyError.fromAny(error).message
: 'Failed to load members'} : 'Failed to load invoices'}
</span> </span>
); );
} }
@@ -8,7 +8,7 @@ import {
import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota'; import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -91,7 +91,7 @@ export const SelfHostTeamCard = () => {
setLoading(false); setLoading(false);
console.error(e); console.error(e);
const error = UserFriendlyError.fromAnyError(e); const error = UserFriendlyError.fromAny(e);
notify.error({ notify.error({
title: error.name, title: error.name,
@@ -119,7 +119,7 @@ export const SelfHostTeamCard = () => {
setLoading(false); setLoading(false);
console.error(e); console.error(e);
const error = UserFriendlyError.fromAnyError(e); const error = UserFriendlyError.fromAny(e);
notify.error({ notify.error({
title: error.name, title: error.name,
@@ -20,12 +20,9 @@ import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { WorkspaceShareSettingService } from '@affine/core/modules/share-setting'; import { WorkspaceShareSettingService } from '@affine/core/modules/share-setting';
import { copyTextToClipboard } from '@affine/core/utils/clipboard'; import { copyTextToClipboard } from '@affine/core/utils/clipboard';
import { emailRegex } from '@affine/core/utils/email-regex'; import { emailRegex } from '@affine/core/utils/email-regex';
import { UserFriendlyError } from '@affine/error';
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql'; import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
import { import { ServerDeploymentType, SubscriptionPlan } from '@affine/graphql';
ServerDeploymentType,
SubscriptionPlan,
UserFriendlyError,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { ExportIcon } from '@blocksuite/icons/rc'; import { ExportIcon } from '@blocksuite/icons/rc';
@@ -129,7 +126,7 @@ export const CloudWorkspaceMembersPanel = ({
message: t['com.affine.payment.resume.success.team.message'](), message: t['com.affine.payment.resume.success.team.message'](),
}); });
} catch (err) { } catch (err) {
const error = UserFriendlyError.fromAnyError(err); const error = UserFriendlyError.fromAny(err);
notify.error({ notify.error({
title: error.name, title: error.name,
message: error.message, message: error.message,
@@ -299,7 +296,7 @@ export const CloudWorkspaceMembersPanel = ({
return ( return (
<span className={styles.errorStyle}> <span className={styles.errorStyle}>
{error {error
? UserFriendlyError.fromAnyError(error).message ? UserFriendlyError.fromAny(error).message
: 'Failed to load members'} : 'Failed to load members'}
</span> </span>
); );
@@ -6,11 +6,8 @@ import {
WorkspaceMembersService, WorkspaceMembersService,
} from '@affine/core/modules/permissions'; } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { import { UserFriendlyError } from '@affine/error';
Permission, import { Permission, WorkspaceMemberStatus } from '@affine/graphql';
UserFriendlyError,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { type I18nString, useI18n } from '@affine/i18n'; import { type I18nString, useI18n } from '@affine/i18n';
import { MoreVerticalIcon } from '@blocksuite/icons/rc'; import { MoreVerticalIcon } from '@blocksuite/icons/rc';
import { import {
@@ -75,7 +72,7 @@ export const MemberList = ({
) : ( ) : (
<span className={styles.errorStyle}> <span className={styles.errorStyle}>
{error {error
? UserFriendlyError.fromAnyError(error).message ? UserFriendlyError.fromAny(error).message
: 'Failed to load members'} : 'Failed to load members'}
</span> </span>
) )
@@ -1,8 +1,9 @@
import { Button } from '@affine/component'; import { Button } from '@affine/component';
import { AuthPageContainer } from '@affine/component/auth-components'; import { AuthPageContainer } from '@affine/component/auth-components';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { BackendError, GraphQLService } from '@affine/core/modules/cloud'; import { GraphQLService } from '@affine/core/modules/cloud';
import { changeEmailMutation, ErrorNames } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { changeEmailMutation } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { type FC, useEffect, useState } from 'react'; import { type FC, useEffect, useState } from 'react';
@@ -33,11 +34,8 @@ export const ConfirmChangeEmail: FC<{
}, },
}) })
.catch(err => { .catch(err => {
if (err instanceof BackendError) { if (UserFriendlyError.fromAny(err).is('INVALID_EMAIL_TOKEN')) {
const userFriendlyError = err.originError; return navigateHelper.jumpToExpired();
if (userFriendlyError.name === ErrorNames.INVALID_EMAIL_TOKEN) {
return navigateHelper.jumpToExpired();
}
} }
throw err; throw err;
}) })
@@ -2,11 +2,8 @@ import { Button } from '@affine/component';
import { AuthPageContainer } from '@affine/component/auth-components'; import { AuthPageContainer } from '@affine/component/auth-components';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { GraphQLService } from '@affine/core/modules/cloud'; import { GraphQLService } from '@affine/core/modules/cloud';
import { import { UserFriendlyError } from '@affine/error';
ErrorNames, import { verifyEmailMutation } from '@affine/graphql';
UserFriendlyError,
verifyEmailMutation,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { type FC, useEffect, useState } from 'react'; import { type FC, useEffect, useState } from 'react';
@@ -35,8 +32,8 @@ export const ConfirmVerifiedEmail: FC<{
}, },
}) })
.catch(error => { .catch(error => {
const userFriendlyError = UserFriendlyError.fromAnyError(error); const userFriendlyError = UserFriendlyError.fromAny(error);
if (userFriendlyError.name === ErrorNames.INVALID_EMAIL_TOKEN) { if (userFriendlyError.is('INVALID_EMAIL_TOKEN')) {
return navigateHelper.jumpToExpired(); return navigateHelper.jumpToExpired();
} }
throw error; throw error;
@@ -3,7 +3,7 @@ import {
ExpiredPage, ExpiredPage,
JoinFailedPage, JoinFailedPage,
} from '@affine/component/member-components'; } from '@affine/component/member-components';
import { ErrorNames, UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
@@ -43,8 +43,8 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
useEffect(() => { useEffect(() => {
if (error && inviteId === targetInviteId) { if (error && inviteId === targetInviteId) {
const err = UserFriendlyError.fromAnyError(error); const err = UserFriendlyError.fromAny(error);
if (err.name === ErrorNames.ALREADY_IN_SPACE) { if (err.is('ALREADY_IN_SPACE')) {
return navigateHelper.jumpToIndex(); return navigateHelper.jumpToIndex();
} }
} }
@@ -1,4 +1,5 @@
import { Button, Loading } from '@affine/component'; import { Button, Loading } from '@affine/component';
import { UserFriendlyError } from '@affine/error';
import { import {
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
@@ -16,11 +17,7 @@ import {
RouteLogic, RouteLogic,
useNavigateHelper, useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper'; } from '../../../components/hooks/use-navigate-helper';
import { import { AuthService, SubscriptionService } from '../../../modules/cloud';
AuthService,
BackendError,
SubscriptionService,
} from '../../../modules/cloud';
import { container } from './subscribe.css'; import { container } from './subscribe.css';
interface ProductTriple { interface ProductTriple {
@@ -160,12 +157,8 @@ export const Component = () => {
setMessage('Redirecting...'); setMessage('Redirecting...');
location.href = checkout; location.href = checkout;
} catch (err) { } catch (err) {
if (err instanceof BackendError) { const e = UserFriendlyError.fromAny(err);
setMessage(err.originError.message); setMessage(e.message);
} else {
console.log(err);
setError('Something went wrong, please contact support.');
}
} }
}).pipe(mergeMap(() => EMPTY)); }).pipe(mergeMap(() => EMPTY));
}) })
@@ -1,6 +1,5 @@
import type { GetWorkspacePageMetaByIdQuery } from '@affine/graphql'; import type { GetWorkspacePageMetaByIdQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,12 +8,12 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, mergeMap } from 'rxjs'; import { EMPTY, mergeMap } from 'rxjs';
import type { DocService } from '../../doc'; import type { DocService } from '../../doc';
import type { GlobalCache } from '../../storage'; import type { GlobalCache } from '../../storage';
import { isBackendError, isNetworkError } from '../error';
import type { CloudDocMetaStore } from '../stores/cloud-doc-meta'; import type { CloudDocMetaStore } from '../stores/cloud-doc-meta';
export type CloudDocMetaType = export type CloudDocMetaType =
@@ -47,13 +46,7 @@ export class CloudDocMeta extends Entity {
return fromPromise( return fromPromise(
this.store.fetchCloudDocMeta(this.workspaceId, this.docId) this.store.fetchCloudDocMeta(this.workspaceId, this.docId)
).pipe( ).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(meta => { mergeMap(meta => {
this.cache.set<CloudDocMetaType>(this.cacheKey, meta); this.cache.set<CloudDocMetaType>(this.cacheKey, meta);
return EMPTY; return EMPTY;
@@ -1,6 +1,5 @@
import type { InvoicesQuery } from '@affine/graphql'; import type { InvoicesQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,10 +8,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs'; import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { InvoicesStore } from '../stores/invoices'; import type { InvoicesStore } from '../stores/invoices';
export type Invoice = NonNullable< export type Invoice = NonNullable<
@@ -50,13 +49,7 @@ export class Invoices extends Entity {
this.pageInvoices$.setValue(data.invoices); this.pageInvoices$.setValue(data.invoices);
return EMPTY; return EMPTY;
}), }),
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => { onStart(() => {
this.pageInvoices$.setValue(undefined); this.pageInvoices$.setValue(undefined);
@@ -1,3 +1,4 @@
import { UserFriendlyError } from '@affine/error';
import { import {
backoffRetry, backoffRetry,
effect, effect,
@@ -12,7 +13,6 @@ import { isEqual } from 'lodash-es';
import { EMPTY, mergeMap } from 'rxjs'; import { EMPTY, mergeMap } from 'rxjs';
import { validateAndReduceImage } from '../../../utils/reduce-image'; import { validateAndReduceImage } from '../../../utils/reduce-image';
import { BackendError } from '../error';
import type { AccountProfile, AuthStore } from '../stores/auth'; import type { AccountProfile, AuthStore } from '../stores/auth';
export interface AuthSessionInfo { export interface AuthSessionInfo {
@@ -114,10 +114,7 @@ export class AuthSession extends Entity {
return null; return null;
} }
} catch (e) { } catch (e) {
if ( if (UserFriendlyError.fromAny(e).is('UNSUPPORTED_CLIENT_VERSION')) {
e instanceof BackendError &&
e.originError.name === 'UNSUPPORTED_CLIENT_VERSION'
) {
return null; return null;
} }
throw e; throw e;
@@ -1,6 +1,5 @@
import type { PricesQuery } from '@affine/graphql'; import type { PricesQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,10 +8,10 @@ import {
mapInto, mapInto,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { exhaustMap } from 'rxjs'; import { exhaustMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { ServerService } from '../services/server'; import type { ServerService } from '../services/server';
import type { SubscriptionStore } from '../stores/subscription'; import type { SubscriptionStore } from '../stores/subscription';
@@ -55,13 +54,7 @@ export class SubscriptionPrices extends Entity {
} }
return this.store.fetchSubscriptionPrices(signal); return this.store.fetchSubscriptionPrices(signal);
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mapInto(this.prices$), mapInto(this.prices$),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)), onStart(() => this.isRevalidating$.next(true)),
@@ -5,7 +5,6 @@ import {
SubscriptionVariant, SubscriptionVariant,
} from '@affine/graphql'; } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -14,10 +13,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs'; import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AuthService } from '../services/auth'; import type { AuthService } from '../services/auth';
import type { ServerService } from '../services/server'; import type { ServerService } from '../services/server';
import type { SubscriptionStore } from '../stores/subscription'; import type { SubscriptionStore } from '../stores/subscription';
@@ -122,13 +121,7 @@ export class Subscription extends Entity {
subscriptions: subscriptions, subscriptions: subscriptions,
}; };
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => { mergeMap(data => {
if (data) { if (data) {
this.store.setCachedSubscriptions( this.store.setCachedSubscriptions(
@@ -1,5 +1,4 @@
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -8,10 +7,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs'; import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AuthService } from '../services/auth'; import type { AuthService } from '../services/auth';
import type { ServerService } from '../services/server'; import type { ServerService } from '../services/server';
import type { UserCopilotQuotaStore } from '../stores/user-copilot-quota'; import type { UserCopilotQuotaStore } from '../stores/user-copilot-quota';
@@ -54,13 +53,7 @@ export class UserCopilotQuota extends Entity {
return aiQuota; return aiQuota;
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => { mergeMap(data => {
if (data) { if (data) {
const { limit, used } = data; const { limit, used } = data;
@@ -1,6 +1,5 @@
import { FeatureType } from '@affine/graphql'; import { FeatureType } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,10 +8,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs'; import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AuthService } from '../services/auth'; import type { AuthService } from '../services/auth';
import type { UserFeatureStore } from '../stores/user-feature'; import type { UserFeatureStore } from '../stores/user-feature';
@@ -59,13 +58,7 @@ export class UserFeature extends Entity {
features: features, features: features,
}; };
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => { mergeMap(data => {
if (data) { if (data) {
this.features$.next(data.features); this.features$.next(data.features);
@@ -1,6 +1,5 @@
import type { QuotaQuery } from '@affine/graphql'; import type { QuotaQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,12 +8,12 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import bytes from 'bytes'; import bytes from 'bytes';
import { EMPTY, map, mergeMap } from 'rxjs'; import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AuthService } from '../services/auth'; import type { AuthService } from '../services/auth';
import type { UserQuotaStore } from '../stores/user-quota'; import type { UserQuotaStore } from '../stores/user-quota';
@@ -79,13 +78,7 @@ export class UserQuota extends Entity {
return { quota, used }; return { quota, used };
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => { mergeMap(data => {
if (data) { if (data) {
const { quota, used } = data; const { quota, used } = data;
@@ -1,6 +1,5 @@
import type { InvoicesQuery } from '@affine/graphql'; import type { InvoicesQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,11 +8,11 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs'; import { EMPTY, map, mergeMap } from 'rxjs';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import { isBackendError, isNetworkError } from '../error';
import type { WorkspaceServerService } from '../services/workspace-server'; import type { WorkspaceServerService } from '../services/workspace-server';
import { InvoicesStore } from '../stores/invoices'; import { InvoicesStore } from '../stores/invoices';
@@ -61,13 +60,7 @@ export class WorkspaceInvoices extends Entity {
this.pageInvoices$.setValue(data.invoices); this.pageInvoices$.setValue(data.invoices);
return EMPTY; return EMPTY;
}), }),
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => { onStart(() => {
this.pageInvoices$.setValue(undefined); this.pageInvoices$.setValue(undefined);
@@ -1,7 +1,6 @@
import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql'; import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql';
import { SubscriptionPlan } from '@affine/graphql'; import { SubscriptionPlan } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -10,11 +9,11 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, mergeMap } from 'rxjs'; import { EMPTY, mergeMap } from 'rxjs';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import { isBackendError, isNetworkError } from '../error';
import type { WorkspaceServerService } from '../services/workspace-server'; import type { WorkspaceServerService } from '../services/workspace-server';
import { SubscriptionStore } from '../stores/subscription'; import { SubscriptionStore } from '../stores/subscription';
export type SubscriptionType = NonNullable< export type SubscriptionType = NonNullable<
@@ -123,13 +122,7 @@ export class WorkspaceSubscription extends Entity {
subscription: subscription, subscription: subscription,
}; };
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => { mergeMap(data => {
if (data && data.subscription && data.workspaceId && this.store) { if (data && data.subscription && data.workspaceId && this.store) {
this.store.setCachedWorkspaceSubscription( this.store.setCachedWorkspaceSubscription(
@@ -1,30 +0,0 @@
import type { UserFriendlyError } from '@affine/graphql';
export class NetworkError extends Error {
constructor(
public readonly originError: Error,
public readonly status?: number
) {
super(`Network error: ${originError.message}`);
this.stack = originError.stack;
}
}
export function isNetworkError(error: Error): error is NetworkError {
return error instanceof NetworkError;
}
export class BackendError extends Error {
get status() {
return this.originError.status;
}
constructor(public readonly originError: UserFriendlyError) {
super(`Server error: ${originError.message}`);
this.stack = originError.stack;
}
}
export function isBackendError(error: Error): error is BackendError {
return error instanceof BackendError;
}
@@ -1,12 +1,6 @@
export type { Invoice } from './entities/invoices'; export type { Invoice } from './entities/invoices';
export { Server } from './entities/server'; export { Server } from './entities/server';
export type { AuthAccountInfo } from './entities/session'; export type { AuthAccountInfo } from './entities/session';
export {
BackendError,
isBackendError,
isNetworkError,
NetworkError,
} from './error';
export { AccountChanged } from './events/account-changed'; export { AccountChanged } from './events/account-changed';
export { AccountLoggedIn } from './events/account-logged-in'; export { AccountLoggedIn } from './events/account-logged-in';
export { AccountLoggedOut } from './events/account-logged-out'; export { AccountLoggedOut } from './events/account-logged-out';
@@ -1,6 +1,5 @@
import type { GetInviteInfoQuery } from '@affine/graphql'; import type { GetInviteInfoQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
fromPromise, fromPromise,
@@ -8,10 +7,10 @@ import {
onComplete, onComplete,
onStart, onStart,
Service, Service,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AcceptInviteStore } from '../stores/accept-invite'; import type { AcceptInviteStore } from '../stores/accept-invite';
import type { InviteInfoStore } from '../stores/invite-info'; import type { InviteInfoStore } from '../stores/invite-info';
@@ -52,14 +51,7 @@ export class AcceptInviteService extends Service {
this.accepted$.next(res); this.accepted$.next(res);
return EMPTY; return EMPTY;
}), }),
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => { onStart(() => {
this.inviteId$.setValue(inviteId); this.inviteId$.setValue(inviteId);
@@ -1,4 +1,5 @@
import { AIProvider } from '@affine/core/blocksuite/ai'; import { AIProvider } from '@affine/core/blocksuite/ai';
import { UserFriendlyError } from '@affine/error';
import type { OAuthProviderType } from '@affine/graphql'; import type { OAuthProviderType } from '@affine/graphql';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { OnEvent, Service } from '@toeverything/infra'; import { OnEvent, Service } from '@toeverything/infra';
@@ -7,7 +8,6 @@ import { distinctUntilChanged, map, skip } from 'rxjs';
import { ApplicationFocused } from '../../lifecycle'; import { ApplicationFocused } from '../../lifecycle';
import type { UrlService } from '../../url'; import type { UrlService } from '../../url';
import { type AuthAccountInfo, AuthSession } from '../entities/session'; import { type AuthAccountInfo, AuthSession } from '../entities/session';
import { BackendError } from '../error';
import { AccountChanged } from '../events/account-changed'; import { AccountChanged } from '../events/account-changed';
import { AccountLoggedIn } from '../events/account-logged-in'; import { AccountLoggedIn } from '../events/account-logged-in';
import { AccountLoggedOut } from '../events/account-logged-out'; import { AccountLoggedOut } from '../events/account-logged-out';
@@ -103,7 +103,7 @@ export class AuthService extends Service {
} catch (e) { } catch (e) {
track.$.$.auth.signInFail({ track.$.$.auth.signInFail({
method: 'magic-link', method: 'magic-link',
reason: e instanceof BackendError ? e.originError.name : 'unknown', reason: UserFriendlyError.fromAny(e).name,
}); });
throw e; throw e;
} }
@@ -119,7 +119,7 @@ export class AuthService extends Service {
} catch (e) { } catch (e) {
track.$.$.auth.signInFail({ track.$.$.auth.signInFail({
method, method,
reason: e instanceof BackendError ? e.originError.name : 'unknown', reason: UserFriendlyError.fromAny(e).name,
}); });
throw e; throw e;
} }
@@ -159,7 +159,7 @@ export class AuthService extends Service {
track.$.$.auth.signInFail({ track.$.$.auth.signInFail({
method: 'oauth', method: 'oauth',
provider, provider,
reason: e instanceof BackendError ? e.originError.name : 'unknown', reason: UserFriendlyError.fromAny(e).name,
}); });
throw e; throw e;
} }
@@ -181,7 +181,7 @@ export class AuthService extends Service {
track.$.$.auth.signInFail({ track.$.$.auth.signInFail({
method: 'oauth', method: 'oauth',
provider, provider,
reason: e instanceof BackendError ? e.originError.name : 'unknown', reason: UserFriendlyError.fromAny(e).name,
}); });
throw e; throw e;
} }
@@ -201,7 +201,7 @@ export class AuthService extends Service {
} catch (e) { } catch (e) {
track.$.$.auth.signInFail({ track.$.$.auth.signInFail({
method: 'password', method: 'password',
reason: e instanceof BackendError ? e.originError.name : 'unknown', reason: UserFriendlyError.fromAny(e).name,
}); });
throw e; throw e;
} }
@@ -1,8 +1,7 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { fromPromise, Service } from '@toeverything/infra'; import { fromPromise, Service } from '@toeverything/infra';
import { BackendError, NetworkError } from '../error';
import type { ServerService } from './server'; import type { ServerService } from './server';
const logger = new DebugLogger('affine:fetch'); const logger = new DebugLogger('affine:fetch');
@@ -46,40 +45,54 @@ export class FetchService extends Service {
abortController.abort('timeout'); abortController.abort('timeout');
}, timeout); }, timeout);
const res = await globalThis let res: Response;
.fetch(new URL(input, this.serverService.server.serverMetadata.baseUrl), {
...init, try {
signal: abortController.signal, res = await globalThis.fetch(
headers: { new URL(input, this.serverService.server.serverMetadata.baseUrl),
...init?.headers, {
'x-affine-version': BUILD_CONFIG.appVersion, ...init,
}, signal: abortController.signal,
}) headers: {
.catch(err => { ...init?.headers,
logger.debug('network error', err); 'x-affine-version': BUILD_CONFIG.appVersion,
throw new NetworkError(err); },
}); }
clearTimeout(timeoutId);
if (res.status === 504) {
const error = new Error('Gateway Timeout');
logger.debug('network error', error);
throw new NetworkError(error, res.status);
}
if (!res.ok) {
logger.warn(
'backend error',
new Error(`${res.status} ${res.statusText}`)
); );
let reason: string | any = ''; } catch (err: any) {
if (res.headers.get('Content-Type')?.includes('application/json')) { throw new UserFriendlyError({
try { status: 504,
reason = await res.json(); code: 'NETWORK_ERROR',
} catch { type: 'NETWORK_ERROR',
// ignore name: 'NETWORK_ERROR',
message: `Network error: ${err.message}`,
stacktrace: err.stack,
});
} finally {
clearTimeout(timeoutId);
}
if (!res.ok) {
if (res.status === 504) {
const error = new Error('Gateway Timeout');
logger.debug('network error', error);
throw new UserFriendlyError({
status: 504,
code: 'NETWORK_ERROR',
type: 'NETWORK_ERROR',
name: 'NETWORK_ERROR',
message: 'Gateway Timeout',
stacktrace: error.stack,
});
} else {
if (res.headers.get('Content-Type') === 'application/json') {
throw UserFriendlyError.fromAny(await res.json());
} else {
throw UserFriendlyError.fromAny(await res.text());
} }
} }
throw new BackendError(UserFriendlyError.fromAnyError(reason));
} }
return res; return res;
}; };
} }
@@ -1,15 +1,13 @@
import { UserFriendlyError } from '@affine/error';
import { import {
gqlFetcherFactory, gqlFetcherFactory,
GraphQLError,
type GraphQLQuery, type GraphQLQuery,
type QueryOptions, type QueryOptions,
type QueryResponse, type QueryResponse,
UserFriendlyError,
} from '@affine/graphql'; } from '@affine/graphql';
import { fromPromise, Service } from '@toeverything/infra'; import { fromPromise, Service } from '@toeverything/infra';
import type { Observable } from 'rxjs'; import type { Observable } from 'rxjs';
import { BackendError } from '../error';
import { AuthService } from './auth'; import { AuthService } from './auth';
import type { FetchService } from './fetch'; import type { FetchService } from './fetch';
@@ -40,16 +38,9 @@ export class GraphQLService extends Service {
try { try {
return await this.rawGql(options); return await this.rawGql(options);
} catch (anyError) { } catch (anyError) {
let error = anyError; const error = UserFriendlyError.fromAny(anyError);
// NOTE(@forehalo): if (error.isStatus(401)) {
// GraphQL error is not present by non-200 status code, but by responding `errors` fields in the body
// So it will never be `BackendError` originally.
if (anyError instanceof GraphQLError) {
error = new BackendError(UserFriendlyError.fromAnyError(anyError));
}
if (error instanceof BackendError && error.status === 403) {
this.framework.get(AuthService).session.revalidate(); this.framework.get(AuthService).session.revalidate();
} }
@@ -1,16 +1,16 @@
import { UserFriendlyError } from '@affine/graphql'; import { type UserFriendlyError } from '@affine/error';
import { import {
backoffRetry, catchErrorInto,
effect, effect,
fromPromise, fromPromise,
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
Service, Service,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { catchError, EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { SelfhostGenerateLicenseStore } from '../stores/selfhost-generate-license'; import type { SelfhostGenerateLicenseStore } from '../stores/selfhost-generate-license';
export class SelfhostGenerateLicenseService extends Service { export class SelfhostGenerateLicenseService extends Service {
@@ -26,22 +26,12 @@ export class SelfhostGenerateLicenseService extends Service {
return fromPromise(async () => { return fromPromise(async () => {
return await this.store.generateKey(sessionId); return await this.store.generateKey(sessionId);
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(key => { mergeMap(key => {
this.licenseKey$.next(key); this.licenseKey$.next(key);
return EMPTY; return EMPTY;
}), }),
catchError(err => { catchErrorInto(this.error$),
this.error$.next(UserFriendlyError.fromAnyError(err));
console.error(err);
return EMPTY;
}),
onStart(() => { onStart(() => {
this.isLoading$.next(true); this.isLoading$.next(true);
}), }),
@@ -1,6 +1,5 @@
import type { License } from '@affine/graphql'; import type { License } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
exhaustMapWithTrailing, exhaustMapWithTrailing,
@@ -9,11 +8,11 @@ import {
onComplete, onComplete,
onStart, onStart,
Service, Service,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, mergeMap } from 'rxjs'; import { EMPTY, mergeMap } from 'rxjs';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import { isBackendError, isNetworkError } from '../error';
import type { SelfhostLicenseStore } from '../stores/selfhost-license'; import type { SelfhostLicenseStore } from '../stores/selfhost-license';
export class SelfhostLicenseService extends Service { export class SelfhostLicenseService extends Service {
@@ -36,13 +35,7 @@ export class SelfhostLicenseService extends Service {
} }
return await this.store.getLicense(currentWorkspaceId, signal); return await this.store.getLicense(currentWorkspaceId, signal);
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => { mergeMap(data => {
if (data) { if (data) {
this.license$.next(data); this.license$.next(data);
@@ -1,5 +1,4 @@
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -7,10 +6,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, mergeMap, switchMap } from 'rxjs'; import { EMPTY, mergeMap, switchMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { TemplateDownloaderStore } from '../store/downloader'; import type { TemplateDownloaderStore } from '../store/downloader';
export class TemplateDownloader extends Entity { export class TemplateDownloader extends Entity {
@@ -29,13 +28,7 @@ export class TemplateDownloader extends Entity {
this.data$.next(data); this.data$.next(data);
return EMPTY; return EMPTY;
}), }),
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => { onStart(() => {
this.isDownloading$.next(true); this.isDownloading$.next(true);
@@ -1,6 +1,5 @@
import type { GetMembersByWorkspaceIdQuery } from '@affine/graphql'; import type { GetMembersByWorkspaceIdQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -8,10 +7,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, map, mergeMap, switchMap } from 'rxjs'; import { EMPTY, map, mergeMap, switchMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import type { WorkspaceMembersStore } from '../stores/members'; import type { WorkspaceMembersStore } from '../stores/members';
@@ -51,13 +50,7 @@ export class WorkspaceMembers extends Entity {
this.pageMembers$.setValue(data.members); this.pageMembers$.setValue(data.members);
return EMPTY; return EMPTY;
}), }),
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => { onStart(() => {
this.pageMembers$.setValue(undefined); this.pageMembers$.setValue(undefined);
@@ -1,6 +1,5 @@
import { DocRole, type GetPageGrantedUsersListQuery } from '@affine/graphql'; import { DocRole, type GetPageGrantedUsersListQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
fromPromise, fromPromise,
@@ -8,10 +7,10 @@ import {
onComplete, onComplete,
onStart, onStart,
Service, Service,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { DocService } from '../../doc'; import type { DocService } from '../../doc';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import type { DocGrantedUsersStore } from '../stores/doc-granted-users'; import type { DocGrantedUsersStore } from '../stores/doc-granted-users';
@@ -65,13 +64,7 @@ export class DocGrantedUsersService extends Service {
return EMPTY; return EMPTY;
}), }),
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => { onStart(() => {
this.isLoading$.setValue(true); this.isLoading$.setValue(true);
@@ -1,5 +1,4 @@
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
fromPromise, fromPromise,
@@ -7,10 +6,10 @@ import {
onComplete, onComplete,
onStart, onStart,
Service, Service,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import type { Member } from '../entities/members'; import type { Member } from '../entities/members';
import type { MemberSearchStore } from '../stores/member-search'; import type { MemberSearchStore } from '../stores/member-search';
@@ -50,13 +49,7 @@ export class MemberSearchService extends Service {
return EMPTY; return EMPTY;
}), }),
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => { onStart(() => {
this.isLoading$.setValue(true); this.isLoading$.setValue(true);
@@ -1,7 +1,6 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import type { WorkspaceQuotaQuery } from '@affine/graphql'; import type { WorkspaceQuotaQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -10,12 +9,12 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import bytes from 'bytes'; import bytes from 'bytes';
import { EMPTY, mergeMap } from 'rxjs'; import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import type { WorkspaceQuotaStore } from '../stores/quota'; import type { WorkspaceQuotaStore } from '../stores/quota';
@@ -76,14 +75,7 @@ export class WorkspaceQuota extends Entity {
); );
return { quota: data, used: data.usedStorageQuota }; return { quota: data, used: data.usedStorageQuota };
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
mergeMap(data => { mergeMap(data => {
if (data) { if (data) {
const { quota, used } = data; const { quota, used } = data;
@@ -1,7 +1,6 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import type { GetWorkspacePublicPagesQuery } from '@affine/graphql'; import type { GetWorkspacePublicPagesQuery } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -10,10 +9,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, mergeMap } from 'rxjs'; import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { GlobalCache } from '../../storage'; import type { GlobalCache } from '../../storage';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import type { ShareDocsStore } from '../stores/share-docs'; import type { ShareDocsStore } from '../stores/share-docs';
@@ -43,13 +42,7 @@ export class ShareDocsList extends Entity {
signal signal
); );
}).pipe( }).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(list => { mergeMap(list => {
this.cache.set('share-docs', list); this.cache.set('share-docs', list);
return EMPTY; return EMPTY;
@@ -1,6 +1,5 @@
import type { GetWorkspacePageByIdQuery, PublicDocMode } from '@affine/graphql'; import type { GetWorkspacePageByIdQuery, PublicDocMode } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,10 +8,10 @@ import {
mapInto, mapInto,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { switchMap } from 'rxjs'; import { switchMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { DocService } from '../../doc'; import type { DocService } from '../../doc';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import type { ShareStore } from '../stores/share'; import type { ShareStore } from '../stores/share';
@@ -44,13 +43,7 @@ export class ShareInfo extends Entity {
signal signal
) )
).pipe( ).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mapInto(this.info$), mapInto(this.info$),
catchErrorInto(this.error$), catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)), onStart(() => this.isRevalidating$.next(true)),
@@ -8,7 +8,8 @@ import {
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocGrantedUsersService } from '@affine/core/modules/permissions'; import { DocGrantedUsersService } from '@affine/core/modules/permissions';
import { ShareInfoService } from '@affine/core/modules/share-doc'; import { ShareInfoService } from '@affine/core/modules/share-doc';
import { DocRole, UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { DocRole } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { InformationIcon } from '@blocksuite/icons/rc'; import { InformationIcon } from '@blocksuite/icons/rc';
@@ -62,7 +63,7 @@ export const MembersPermission = ({
await docGrantedUsersService.updateDocDefaultRole(docRole); await docGrantedUsersService.updateDocDefaultRole(docRole);
shareInfoService.shareInfo.revalidate(); shareInfoService.shareInfo.revalidate();
} catch (error) { } catch (error) {
const err = UserFriendlyError.fromAnyError(error); const err = UserFriendlyError.fromAny(error);
notify.error({ notify.error({
title: err.name, title: err.name,
message: err.message, message: err.message,
@@ -1,7 +1,8 @@
import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component'; import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { ShareInfoService } from '@affine/core/modules/share-doc'; import { ShareInfoService } from '@affine/core/modules/share-doc';
import { PublicDocMode, UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { PublicDocMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { import {
@@ -74,7 +75,7 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
icon: <SingleSelectCheckSolidIcon color={cssVar('primaryColor')} />, icon: <SingleSelectCheckSolidIcon color={cssVar('primaryColor')} />,
}); });
} catch (error) { } catch (error) {
const err = UserFriendlyError.fromAnyError(error); const err = UserFriendlyError.fromAny(error);
notify.error({ notify.error({
title: err.name, title: err.name,
message: err.message, message: err.message,
@@ -14,11 +14,8 @@ import {
type Member, type Member,
MemberSearchService, MemberSearchService,
} from '@affine/core/modules/permissions'; } from '@affine/core/modules/permissions';
import { import { UserFriendlyError } from '@affine/error';
DocRole, import { DocRole, WorkspaceMemberStatus } from '@affine/graphql';
UserFriendlyError,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc'; import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
@@ -113,7 +110,7 @@ export const InviteMemberEditor = ({
title: t['Invitation sent'](), title: t['Invitation sent'](),
}); });
} catch (error) { } catch (error) {
const err = UserFriendlyError.fromAnyError(error); const err = UserFriendlyError.fromAny(error);
notify.error({ notify.error({
title: t[`error.${err.name}`](err.data), title: t[`error.${err.name}`](err.data),
}); });
@@ -16,7 +16,8 @@ import {
GuardService, GuardService,
WorkspacePermissionService, WorkspacePermissionService,
} from '@affine/core/modules/permissions'; } from '@affine/core/modules/permissions';
import { DocRole, UserFriendlyError } from '@affine/graphql'; import { UserFriendlyError } from '@affine/error';
import { DocRole } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
@@ -161,7 +162,7 @@ const Options = ({
}); });
} }
} catch (error) { } catch (error) {
const err = UserFriendlyError.fromAnyError(error); const err = UserFriendlyError.fromAny(error);
notify.error({ notify.error({
title: t[`error.${err.name}`](err.data), title: t[`error.${err.name}`](err.data),
}); });
@@ -219,7 +220,7 @@ const Options = ({
await docGrantedUsersService.revokeUsersRole(userId); await docGrantedUsersService.revokeUsersRole(userId);
docGrantedUsersService.loadMore(); docGrantedUsersService.loadMore();
} catch (error) { } catch (error) {
const err = UserFriendlyError.fromAnyError(error); const err = UserFriendlyError.fromAny(error);
notify.error({ notify.error({
title: t[`error.${err.name}`](err.data), title: t[`error.${err.name}`](err.data),
}); });
@@ -1,7 +1,6 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import type { GetWorkspaceConfigQuery, InviteLink } from '@affine/graphql'; import type { GetWorkspaceConfigQuery, InviteLink } from '@affine/graphql';
import { import {
backoffRetry,
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
@@ -9,10 +8,10 @@ import {
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
smartRetry,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
import type { WorkspaceShareSettingStore } from '../stores/share-setting'; import type { WorkspaceShareSettingStore } from '../stores/share-setting';
@@ -45,14 +44,7 @@ export class WorkspaceShareSetting extends Entity {
signal signal
) )
).pipe( ).pipe(
backoffRetry({ smartRetry(),
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
mergeMap(value => { mergeMap(value => {
if (value) { if (value) {
this.enableAi$.next(value.enableAi); this.enableAi$.next(value.enableAi);
+1
View File
@@ -11,6 +11,7 @@
{ "path": "../../common/debug" }, { "path": "../../common/debug" },
{ "path": "../electron-api" }, { "path": "../electron-api" },
{ "path": "../../common/env" }, { "path": "../../common/env" },
{ "path": "../../common/error" },
{ "path": "../graphql" }, { "path": "../graphql" },
{ "path": "../i18n" }, { "path": "../i18n" },
{ "path": "../../common/nbstore" }, { "path": "../../common/nbstore" },
+1
View File
@@ -26,6 +26,7 @@
"dependencies": { "dependencies": {
"@affine/debug": "workspace:*", "@affine/debug": "workspace:*",
"@affine/env": "workspace:*", "@affine/env": "workspace:*",
"@affine/error": "workspace:*",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21" "lodash-es": "^4.17.21"
-83
View File
@@ -1,83 +0,0 @@
import { GraphQLError as BaseGraphQLError } from 'graphql';
import { type ErrorDataUnion, ErrorNames } from './schema';
export interface UserFriendlyErrorResponse {
status: number;
code: string;
type: string;
name: ErrorNames;
message: string;
data?: any;
stacktrace?: string;
}
export class UserFriendlyError
extends Error
implements UserFriendlyErrorResponse
{
readonly status = this.response.status;
readonly code = this.response.code;
readonly type = this.response.type;
readonly rawName = this.response.name;
override readonly message = this.response.message;
readonly data = this.response.data;
readonly stacktrace = this.response.stacktrace;
override get name() {
if (this.rawName in ErrorNames) {
return this.rawName;
}
return ErrorNames.INTERNAL_SERVER_ERROR;
}
static fromAnyError(response: any) {
if (response instanceof GraphQLError) {
return new UserFriendlyError(response.extensions);
}
if (
'originError' in response &&
response.originError instanceof UserFriendlyError
) {
return response.originError as UserFriendlyError;
}
if (
response &&
typeof response === 'object' &&
response.type &&
response.name
) {
return new UserFriendlyError(response);
}
return new UserFriendlyError({
status: 500,
code: 'INTERNAL_SERVER_ERROR',
type: 'INTERNAL_SERVER_ERROR',
name: ErrorNames.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
});
}
constructor(private readonly response: UserFriendlyErrorResponse) {
super(response.message);
}
}
export class GraphQLError extends BaseGraphQLError {
// @ts-expect-error better to be a known type without any type casting
override extensions!: UserFriendlyErrorResponse;
}
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` }
>;
};
+1 -1
View File
@@ -1,8 +1,8 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { GraphQLError } from '@affine/error';
import type { ExecutionResult } from 'graphql'; import type { ExecutionResult } from 'graphql';
import { isNil, isObject, merge } from 'lodash-es'; import { isNil, isObject, merge } from 'lodash-es';
import { GraphQLError } from './error';
import type { GraphQLQuery } from './graphql'; import type { GraphQLQuery } from './graphql';
import type { Mutations, Queries } from './schema'; import type { Mutations, Queries } from './schema';
-1
View File
@@ -1,4 +1,3 @@
export * from './error';
export * from './fetcher'; export * from './fetcher';
export * from './graphql'; export * from './graphql';
export * from './schema'; export * from './schema';
+2 -1
View File
@@ -8,6 +8,7 @@
}, },
"references": [ "references": [
{ "path": "../../common/debug" }, { "path": "../../common/debug" },
{ "path": "../../common/env" } { "path": "../../common/env" },
{ "path": "../../common/error" }
] ]
} }
+4
View File
@@ -7067,6 +7067,10 @@ export function useAFFiNEI18N(): {
* `An internal error occurred.` * `An internal error occurred.`
*/ */
["error.INTERNAL_SERVER_ERROR"](): string; ["error.INTERNAL_SERVER_ERROR"](): string;
/**
* `Network error.`
*/
["error.NETWORK_ERROR"](): string;
/** /**
* `Too many requests.` * `Too many requests.`
*/ */
@@ -1758,6 +1758,7 @@
"com.affine.fail-to-join-workspace.description-1": "Unable to join <1/> <2>{{workspaceName}}</2> due to insufficient seats available.", "com.affine.fail-to-join-workspace.description-1": "Unable to join <1/> <2>{{workspaceName}}</2> due to insufficient seats available.",
"com.affine.fail-to-join-workspace.description-2": "Please contact your workspace owner to add more seats.", "com.affine.fail-to-join-workspace.description-2": "Please contact your workspace owner to add more seats.",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.", "error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.", "error.TOO_MANY_REQUEST": "Too many requests.",
"error.NOT_FOUND": "Resource not found.", "error.NOT_FOUND": "Resource not found.",
"error.BAD_REQUEST": "Bad request.", "error.BAD_REQUEST": "Bad request.",
+14 -1
View File
@@ -599,12 +599,18 @@ export const PackageList = [
name: '@affine/env', name: '@affine/env',
workspaceDependencies: [], workspaceDependencies: [],
}, },
{
location: 'packages/common/error',
name: '@affine/error',
workspaceDependencies: [],
},
{ {
location: 'packages/common/infra', location: 'packages/common/infra',
name: '@toeverything/infra', name: '@toeverything/infra',
workspaceDependencies: [ workspaceDependencies: [
'packages/common/debug', 'packages/common/debug',
'packages/common/env', 'packages/common/env',
'packages/common/error',
'packages/frontend/templates', 'packages/frontend/templates',
], ],
}, },
@@ -704,6 +710,7 @@ export const PackageList = [
workspaceDependencies: [ workspaceDependencies: [
'packages/common/debug', 'packages/common/debug',
'packages/frontend/electron-api', 'packages/frontend/electron-api',
'packages/common/error',
'packages/frontend/graphql', 'packages/frontend/graphql',
'packages/frontend/i18n', 'packages/frontend/i18n',
'tools/utils', 'tools/utils',
@@ -718,6 +725,7 @@ export const PackageList = [
'packages/common/debug', 'packages/common/debug',
'packages/frontend/electron-api', 'packages/frontend/electron-api',
'packages/common/env', 'packages/common/env',
'packages/common/error',
'packages/frontend/graphql', 'packages/frontend/graphql',
'packages/frontend/i18n', 'packages/frontend/i18n',
'packages/common/nbstore', 'packages/common/nbstore',
@@ -735,7 +743,11 @@ export const PackageList = [
{ {
location: 'packages/frontend/graphql', location: 'packages/frontend/graphql',
name: '@affine/graphql', name: '@affine/graphql',
workspaceDependencies: ['packages/common/debug', 'packages/common/env'], workspaceDependencies: [
'packages/common/debug',
'packages/common/env',
'packages/common/error',
],
}, },
{ {
location: 'packages/frontend/i18n', location: 'packages/frontend/i18n',
@@ -890,6 +902,7 @@ export type PackageName =
| '@affine/server' | '@affine/server'
| '@affine/debug' | '@affine/debug'
| '@affine/env' | '@affine/env'
| '@affine/error'
| '@toeverything/infra' | '@toeverything/infra'
| '@affine/nbstore' | '@affine/nbstore'
| '@affine/admin' | '@affine/admin'
+1
View File
@@ -95,6 +95,7 @@
{ "path": "./packages/backend/server" }, { "path": "./packages/backend/server" },
{ "path": "./packages/common/debug" }, { "path": "./packages/common/debug" },
{ "path": "./packages/common/env" }, { "path": "./packages/common/env" },
{ "path": "./packages/common/error" },
{ "path": "./packages/common/infra" }, { "path": "./packages/common/infra" },
{ "path": "./packages/common/nbstore" }, { "path": "./packages/common/nbstore" },
{ "path": "./packages/frontend/admin" }, { "path": "./packages/frontend/admin" },
+16 -1
View File
@@ -277,6 +277,7 @@ __metadata:
"@affine-tools/utils": "workspace:*" "@affine-tools/utils": "workspace:*"
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/electron-api": "workspace:*" "@affine/electron-api": "workspace:*"
"@affine/error": "workspace:*"
"@affine/graphql": "workspace:*" "@affine/graphql": "workspace:*"
"@affine/i18n": "workspace:*" "@affine/i18n": "workspace:*"
"@atlaskit/pragmatic-drag-and-drop": "npm:^1.4.0" "@atlaskit/pragmatic-drag-and-drop": "npm:^1.4.0"
@@ -363,6 +364,7 @@ __metadata:
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/electron-api": "workspace:*" "@affine/electron-api": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@affine/error": "workspace:*"
"@affine/graphql": "workspace:*" "@affine/graphql": "workspace:*"
"@affine/i18n": "workspace:*" "@affine/i18n": "workspace:*"
"@affine/nbstore": "workspace:*" "@affine/nbstore": "workspace:*"
@@ -567,12 +569,24 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@affine/error@workspace:*, @affine/error@workspace:packages/common/error":
version: 0.0.0-use.local
resolution: "@affine/error@workspace:packages/common/error"
dependencies:
vitest: "npm:^3.0.7"
peerDependencies:
"@affine/graphql": "workspace:*"
graphql: ^16.9.0
languageName: unknown
linkType: soft
"@affine/graphql@workspace:*, @affine/graphql@workspace:packages/frontend/graphql": "@affine/graphql@workspace:*, @affine/graphql@workspace:packages/frontend/graphql":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine/graphql@workspace:packages/frontend/graphql" resolution: "@affine/graphql@workspace:packages/frontend/graphql"
dependencies: dependencies:
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@affine/error": "workspace:*"
"@graphql-codegen/add": "npm:^5.0.3" "@graphql-codegen/add": "npm:^5.0.3"
"@graphql-codegen/cli": "npm:5.0.5" "@graphql-codegen/cli": "npm:5.0.5"
"@graphql-codegen/typescript": "npm:^4.1.2" "@graphql-codegen/typescript": "npm:^4.1.2"
@@ -13479,6 +13493,7 @@ __metadata:
dependencies: dependencies:
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@affine/error": "workspace:*"
"@affine/templates": "workspace:*" "@affine/templates": "workspace:*"
"@datastructures-js/binary-search-tree": "npm:^5.3.2" "@datastructures-js/binary-search-tree": "npm:^5.3.2"
"@emotion/react": "npm:^11.14.0" "@emotion/react": "npm:^11.14.0"
@@ -32883,7 +32898,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"vitest@npm:3.0.7, vitest@npm:^3.0.0": "vitest@npm:3.0.7, vitest@npm:^3.0.0, vitest@npm:^3.0.7":
version: 3.0.7 version: 3.0.7
resolution: "vitest@npm:3.0.7" resolution: "vitest@npm:3.0.7"
dependencies: dependencies: