feat(server): introduce user friendly server errors (#7111)

This commit is contained in:
liuyi
2024-06-17 11:30:58 +08:00
committed by GitHub
parent 5307a55f8a
commit 54fc1197ad
65 changed files with 3170 additions and 924 deletions

View File

@@ -11,6 +11,7 @@ import {
type GraphQLQuery,
type QueryOptions,
type RequestOptions,
UserFriendlyError,
} from '@affine/graphql';
import {
GeneralNetworkError,
@@ -33,27 +34,16 @@ function codeToError(code: number) {
}
}
type ErrorType =
| GraphQLError[]
| GraphQLError
| { status: number }
| Error
| string;
export function resolveError(err: any) {
const standardError =
err instanceof GraphQLError
? new UserFriendlyError(err.extensions)
: UserFriendlyError.fromAnyError(err);
export function resolveError(src: ErrorType) {
if (typeof src === 'string') {
return new GeneralNetworkError(src);
} else if (src instanceof GraphQLError || Array.isArray(src)) {
// only resolve the first error
const error = Array.isArray(src) ? src.at(0) : src;
const code = error?.extensions?.code;
return codeToError(code ?? 500);
} else {
return codeToError(src instanceof Error ? 500 : src.status);
}
return codeToError(standardError.status);
}
export function handleError(src: ErrorType) {
export function handleError(src: any) {
const err = resolveError(src);
if (err instanceof UnauthorizedError) {
getCurrentStore().set(showAILoginRequiredAtom, true);
@@ -66,8 +56,7 @@ const fetcher = async <Query extends GraphQLQuery>(
) => {
try {
return await defaultFetcher<Query>(options);
} catch (_err) {
const err = _err as GraphQLError | GraphQLError[] | Error | string;
} catch (err) {
throw handleError(err);
}
};

View File

@@ -1,4 +1,5 @@
import { DebugLogger } from '@affine/debug';
import { UserFriendlyError } from '@affine/graphql';
import { fromPromise, Service } from '@toeverything/infra';
import { BackendError, NetworkError } from '../error';
@@ -75,9 +76,7 @@ export class FetchService extends Service {
// ignore
}
}
throw new BackendError(
new Error(`${res.status} ${res.statusText}`, reason)
);
throw new BackendError(UserFriendlyError.fromAnyError(reason));
}
return res;
};

View File

@@ -1,9 +1,9 @@
import {
gqlFetcherFactory,
GraphQLError,
type GraphQLQuery,
type QueryOptions,
type QueryResponse,
UserFriendlyError,
} from '@affine/graphql';
import { fromPromise, Service } from '@toeverything/infra';
import type { Observable } from 'rxjs';
@@ -39,15 +39,13 @@ export class GraphQLService extends Service {
try {
return await this.rawGql(options);
} catch (err) {
if (err instanceof Array) {
for (const error of err) {
if (error instanceof GraphQLError && error.extensions?.code === 403) {
this.framework.get(AuthService).session.revalidate();
}
}
throw new BackendError(new Error('Graphql Error'));
const standardError = UserFriendlyError.fromAnyError(err);
if (standardError.status === 403) {
this.framework.get(AuthService).session.revalidate();
}
throw err;
throw new BackendError(standardError);
}
};
}

View File

@@ -1,10 +1,10 @@
import {
deleteBlobMutation,
fetcher,
findGraphQLError,
getBaseUrl,
listBlobsQuery,
setBlobMutation,
UserFriendlyError,
} from '@affine/graphql';
import type { BlobStorage } from '@toeverything/infra';
import { BlobStorageOverCapacity } from '@toeverything/infra';
@@ -44,13 +44,9 @@ export class CloudBlobStorage implements BlobStorage {
})
.then(res => res.setBlob)
.catch(err => {
const uploadError = findGraphQLError(
err,
e => e.extensions.code === 413
);
if (uploadError) {
throw new BlobStorageOverCapacity(uploadError);
const error = UserFriendlyError.fromAnyError(err);
if (error.status === 413) {
throw new BlobStorageOverCapacity(error);
}
throw err;

View File

@@ -1,4 +1,9 @@
import { DebugLogger } from '@affine/debug';
import {
ErrorNames,
UserFriendlyError,
type UserFriendlyErrorResponse,
} from '@affine/graphql';
import type { DocServer } from '@toeverything/infra';
import { throwIfAborted } from '@toeverything/infra';
import type { Socket } from 'socket.io-client';
@@ -9,6 +14,8 @@ import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
const logger = new DebugLogger('affine-cloud-doc-engine-server');
type WebsocketResponse<T> = { error: UserFriendlyErrorResponse } | { data: T };
export class CloudDocEngineServer implements DocServer {
interruptCb: ((reason: string) => void) | null = null;
SEND_TIMEOUT = 30000;
@@ -31,21 +38,24 @@ export class CloudDocEngineServer implements DocServer {
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
const response:
| { error: any }
| { data: { missing: string; state: string; timestamp: number } } =
await this.socket.timeout(this.SEND_TIMEOUT).emitWithAck('doc-load-v2', {
const response: WebsocketResponse<{
missing: string;
state: string;
timestamp: number;
}> = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('doc-load-v2', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
});
if ('error' in response) {
// TODO: result `EventError` with server
if (response.error.code === 'DOC_NOT_FOUND') {
const error = new UserFriendlyError(response.error);
if (error.name === ErrorNames.DOC_NOT_FOUND) {
return null;
} else {
throw new Error(response.error.message);
throw error;
}
} else {
return {
@@ -60,11 +70,7 @@ export class CloudDocEngineServer implements DocServer {
async pushDoc(docId: string, data: Uint8Array) {
const payload = await uint8ArrayToBase64(data);
const response: {
// TODO: reuse `EventError` with server
error?: any;
data: { timestamp: number };
} = await this.socket
const response: WebsocketResponse<{ timestamp: number }> = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('client-update-v2', {
workspaceId: this.workspaceId,
@@ -72,38 +78,34 @@ export class CloudDocEngineServer implements DocServer {
updates: [payload],
});
// TODO: raise error with different code to users
if (response.error) {
if ('error' in response) {
logger.error('client-update-v2 error', {
workspaceId: this.workspaceId,
guid: docId,
response,
});
throw new Error(response.error);
throw new UserFriendlyError(response.error);
}
return { serverClock: response.data.timestamp };
}
async loadServerClock(after: number): Promise<Map<string, number>> {
const response: {
// TODO: reuse `EventError` with server
error?: any;
data: Record<string, number>;
} = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('client-pre-sync', {
workspaceId: this.workspaceId,
timestamp: after,
});
const response: WebsocketResponse<Record<string, number>> =
await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('client-pre-sync', {
workspaceId: this.workspaceId,
timestamp: after,
});
if (response.error) {
if ('error' in response) {
logger.error('client-pre-sync error', {
workspaceId: this.workspaceId,
response,
});
throw new Error(response.error);
throw new UserFriendlyError(response.error);
}
return new Map(Object.entries(response.data));

View File

@@ -7,7 +7,6 @@ config:
declarationKind: interface
avoidOptionals: true
preResolveTypes: true
onlyOperationTypes: true
namingConvention:
enumValues: keep
scalars:

View File

@@ -102,11 +102,8 @@ describe('GraphQL fetcher', () => {
)
);
await expect(gql({ query, variables: void 0 })).rejects
.toMatchInlineSnapshot(`
[
[GraphQLError: error],
]
`);
await expect(
gql({ query, variables: void 0 })
).rejects.toMatchInlineSnapshot(`[GraphQLError: error]`);
});
});

View File

@@ -1,26 +1,59 @@
import { GraphQLError as BaseGraphQLError } from 'graphql';
import { identity } from 'lodash-es';
interface KnownGraphQLErrorExtensions {
code: number;
status: string;
originalError?: unknown;
import { type ErrorDataUnion, ErrorNames } from './schema';
export interface UserFriendlyErrorResponse {
status: number;
code: string;
type: string;
name: ErrorNames;
message: string;
args?: any;
stacktrace?: string;
}
export class UserFriendlyError implements UserFriendlyErrorResponse {
status = this.response.status;
code = this.response.code;
type = this.response.type;
name = this.response.name;
message = this.response.message;
args = this.response.args;
stacktrace = this.response.stacktrace;
static fromAnyError(response: any) {
if (response instanceof GraphQLError) {
return new UserFriendlyError(response.extensions);
}
if (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) {}
}
export class GraphQLError extends BaseGraphQLError {
// @ts-expect-error better to be a known type without any type casting
override extensions!: KnownGraphQLErrorExtensions;
}
export function findGraphQLError(
errOrArr: any,
filter: (err: GraphQLError) => boolean = identity
): GraphQLError | undefined {
if (errOrArr instanceof GraphQLError) {
return filter(errOrArr) ? errOrArr : undefined;
} else if (Array.isArray(errOrArr)) {
return errOrArr.find(err => err instanceof GraphQLError && filter(err));
} else {
return undefined;
}
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` }
>;
};

View File

@@ -195,9 +195,9 @@ export const gqlFetcherFactory = (
const result = (await res.json()) as ExecutionResult;
if (res.status >= 400 || result.errors) {
if (result.errors && result.errors.length > 0) {
throw result.errors.map(
error => new GraphQLError(error.message, error)
);
// throw the first error is enough
const firstError = result.errors[0];
throw new GraphQLError(firstError.message, firstError);
} else {
throw new GraphQLError('Empty GraphQL error body');
}

File diff suppressed because it is too large Load Diff