Skip to content

Commit a0ea2e6

Browse files
committed
Migrate error handling/formatting for Yoga
1 parent bf9c29f commit a0ea2e6

File tree

4 files changed

+86
-96
lines changed

4 files changed

+86
-96
lines changed

src/core/exception/exception.filter.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { HttpAdapter } from '~/core/http';
1010
import { ConfigService } from '../config/config.service';
1111
import { ILogger, Logger, LogLevel } from '../logger';
1212
import { ValidationException } from '../validation';
13-
import { ExceptionJson, ExceptionNormalizer } from './exception.normalizer';
13+
import {
14+
ExceptionJson,
15+
ExceptionNormalizer,
16+
NormalizedException,
17+
} from './exception.normalizer';
1418
import { isFromHackAttempt } from './is-from-hack-attempt';
1519

1620
@Catch()
@@ -47,16 +51,11 @@ export class ExceptionFilter implements IExceptionFilter {
4751
this.logIt(normalized, exception);
4852

4953
if (args.getType() === 'graphql') {
50-
this.respondToGraphQL(normalized, args);
54+
// re-throw our result so that the GraphQL Server picks it up
55+
throw new NormalizedException(normalized);
5156
}
52-
await this.respondToHttp(normalized, args);
53-
}
5457

55-
private respondToGraphQL(ex: ExceptionJson, _args: ArgumentsHost) {
56-
const { message, stack, ...extensions } = ex;
57-
const out = { message, stack, extensions };
58-
// re-throw our result so that Apollo Server picks it up
59-
throw Object.assign(new Error(), out);
58+
await this.respondToHttp(normalized, args);
6059
}
6160

6261
private async respondToHttp(ex: ExceptionJson, args: ArgumentsHost) {
@@ -96,10 +95,6 @@ export class ExceptionFilter implements IExceptionFilter {
9695
// Jest will log exceptions, don't duplicate.
9796
return;
9897
}
99-
if (info.code === 'PersistedQueryNotFound') {
100-
// This is the normal flow. Tells the client to send full operation to be cached.
101-
return;
102-
}
10398

10499
if (info.codes.has('Validation')) {
105100
const inputErrors =

src/core/exception/exception.normalizer.ts

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
import { ApolloServerErrorCode as ApolloCode } from '@apollo/server/errors';
21
import { ArgumentsHost, Inject, Injectable } from '@nestjs/common';
32
// eslint-disable-next-line no-restricted-imports,@seedcompany/no-restricted-imports
43
import * as Nest from '@nestjs/common';
54
import { GqlExecutionContext } from '@nestjs/graphql';
6-
import {
7-
entries,
8-
isNotFalsy,
9-
setHas,
10-
setOf,
11-
simpleSwitch,
12-
} from '@seedcompany/common';
5+
import { entries, isNotFalsy, simpleSwitch } from '@seedcompany/common';
136
import * as Edge from 'edgedb';
147
import * as EdgeDBTags from 'edgedb/dist/errors/tags.js';
158
import { GraphQLError } from 'graphql';
@@ -35,9 +28,12 @@ import { normalizeFramePath } from './normalize-frame-path';
3528

3629
interface NormalizeParams {
3730
ex: Error;
38-
/** Errors thrown in Query/Mutation/Controller methods will have this. */
31+
/** Errors thrown in Query/Mutation/Controller methods will have this via ExceptionFilter. */
3932
context?: ArgumentsHost;
40-
/** Errors thrown in ResolveField methods will have this. */
33+
/**
34+
* Errors thrown in ResolveField methods (or other GQL communication problems) will have this.
35+
* This is essentially mutatally exclusuve with {@link context}.
36+
*/
4137
gql?: GraphQLError;
4238
}
4339

@@ -49,6 +45,16 @@ export interface ExceptionJson {
4945
[key: string]: unknown;
5046
}
5147

48+
/**
49+
* Denote normalization has already happened for this error.
50+
* So the GQL server can know acturately if needs to normalize or not.
51+
*/
52+
export class NormalizedException extends Error {
53+
constructor(readonly normalized: ExceptionJson) {
54+
super(normalized.message);
55+
}
56+
}
57+
5258
@Injectable()
5359
export class ExceptionNormalizer {
5460
constructor(
@@ -159,14 +165,12 @@ export class ExceptionNormalizer {
159165
};
160166
}
161167

162-
// Apollo Errors
163168
if (ex instanceof GraphQLError) {
164-
const codes = this.errorToCodes(ex);
165-
const isClient = setHas(
166-
apolloErrorCodesThatAreClientProblem,
167-
ex.extensions.code!,
168-
);
169-
return { codes: [codes[0], 'GraphQL', isClient ? 'Client' : 'Server'] };
169+
const isClient =
170+
(ex.extensions.http?.status ?? 500) < 500 ||
171+
// Guessing here. No execution path - client problem.
172+
!ex.path;
173+
return { codes: ['GraphQL', isClient ? 'Client' : 'Server'] };
170174
}
171175

172176
// Bad output from API, that doesn't match the schema
@@ -199,7 +203,7 @@ export class ExceptionNormalizer {
199203
? this.resources.getByEdgeDB(type).name
200204
: type;
201205

202-
if (gql?.path) {
206+
if (gql?.path && gql.path.length > 1) {
203207
// This error was thrown from a field resolver.
204208
// Because this is not directly from user input, it is a server error.
205209
// Still make the error nicer.
@@ -311,12 +315,3 @@ export class ExceptionNormalizer {
311315
return type.name.replace(/(Exception|Error)$/, '');
312316
}
313317
}
314-
315-
const apolloErrorCodesThatAreClientProblem = setOf([
316-
ApolloCode.GRAPHQL_PARSE_FAILED,
317-
ApolloCode.GRAPHQL_VALIDATION_FAILED,
318-
ApolloCode.PERSISTED_QUERY_NOT_FOUND,
319-
ApolloCode.PERSISTED_QUERY_NOT_SUPPORTED,
320-
ApolloCode.BAD_USER_INPUT,
321-
ApolloCode.BAD_REQUEST,
322-
]);
Lines changed: 55 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
import { unwrapResolverError } from '@apollo/server/errors';
21
import { Injectable } from '@nestjs/common';
32
import { ModuleRef } from '@nestjs/core';
4-
import {
5-
GraphQLErrorExtensions as ErrorExtensions,
6-
GraphQLFormattedError as FormattedError,
7-
GraphQLError,
8-
} from 'graphql';
3+
import { GraphQLError } from 'graphql';
4+
import { handleStreamOrSingleExecutionResult } from 'graphql-yoga';
95
import { LazyGetter } from 'lazy-get-decorator';
10-
import { JsonSet } from '~/common';
116
import { ExceptionFilter } from '../exception/exception.filter';
12-
import { ExceptionNormalizer } from '../exception/exception.normalizer';
7+
import {
8+
ExceptionNormalizer,
9+
NormalizedException,
10+
} from '../exception/exception.normalizer';
11+
import { Plugin } from './plugin.decorator';
1312

1413
declare module 'graphql' {
1514
interface GraphQLErrorExtensions {
@@ -19,6 +18,7 @@ declare module 'graphql' {
1918
}
2019
}
2120

21+
@Plugin()
2222
@Injectable()
2323
export class GraphqlErrorFormatter {
2424
constructor(private readonly moduleRef: ModuleRef) {}
@@ -30,60 +30,61 @@ export class GraphqlErrorFormatter {
3030
return this.moduleRef.get(ExceptionFilter, { strict: false });
3131
}
3232

33-
formatError = (
34-
formatted: FormattedError,
35-
error: unknown | /* but probably a */ GraphQLError,
36-
): FormattedError => {
37-
const { message, ...extensions } = this.getErrorExtensions(
38-
formatted,
39-
error,
40-
);
33+
onValidate: Plugin['onValidate'] =
34+
() =>
35+
({ result, setResult }) => {
36+
if (result.length > 0) {
37+
const errors = result.map((error) => this.formatError(error));
38+
setResult(errors);
39+
}
40+
};
4141

42-
const codes = (extensions.codes ??= new JsonSet(['Server']));
43-
delete extensions.code;
42+
onExecute: Plugin['onExecute'] = () => ({
43+
onExecuteDone: (params) =>
44+
handleStreamOrSingleExecutionResult(params, ({ result, setResult }) => {
45+
if (result.errors && result.errors.length > 0) {
46+
const errors = result.errors.map((error) => this.formatError(error));
47+
setResult({ ...result, errors });
48+
}
49+
}),
50+
});
4451

45-
// Schema & validation errors don't have meaningful stack traces, so remove them
46-
const worthlessTrace = codes.has('Validation') || codes.has('GraphQL');
47-
if (worthlessTrace) {
48-
delete extensions.stacktrace;
52+
formatError = (error: unknown) => {
53+
if (!(error instanceof GraphQLError)) {
54+
// I don't think this happens.
55+
return new GraphQLError(
56+
error instanceof Error ? error.message : String(error),
57+
);
4958
}
5059

51-
return {
52-
message:
53-
message && typeof message === 'string' ? message : formatted.message,
54-
extensions,
55-
locations: formatted.locations,
56-
path: formatted.path,
57-
};
58-
};
60+
const normalized =
61+
error.originalError instanceof NormalizedException
62+
? error.originalError.normalized
63+
: this.normalizer.normalize({
64+
ex: error.originalError ?? error,
65+
gql: error,
66+
});
5967

60-
private getErrorExtensions(
61-
formatted: FormattedError,
62-
error: unknown | /* but probably a */ GraphQLError,
63-
): ErrorExtensions {
64-
// ExceptionNormalizer has already been called
65-
if (formatted.extensions?.codes instanceof Set) {
66-
return { ...formatted.extensions };
68+
// If this is an error has not gone through the ExceptionFilter,
69+
// the logging was skipped - log error now.
70+
if (!(error.originalError instanceof NormalizedException)) {
71+
this.filter.logIt(normalized, error.originalError ?? error);
6772
}
6873

69-
const original = unwrapResolverError(error);
70-
// Safety check
71-
if (!(original instanceof Error)) {
72-
return { ...formatted.extensions };
74+
const { message, stack, code: _, ...extensions } = normalized;
75+
const { codes } = extensions;
76+
77+
// Schema & validation errors don't have meaningful stack traces, so remove them
78+
const worthlessTrace = codes.has('Validation') || codes.has('GraphQL');
79+
if (!worthlessTrace) {
80+
extensions.stacktrace = stack.split('\n');
7381
}
7482

75-
// Some errors do not go through the global exception filter.
76-
// ResolveField() calls is one of them.
77-
// Normalized & log here.
78-
const normalized = this.normalizer.normalize({
79-
ex: original,
80-
gql: error instanceof GraphQLError ? error : undefined,
83+
return new GraphQLError(message, {
84+
nodes: error.nodes,
85+
positions: error.positions,
86+
path: error.path,
87+
extensions: { ...error.extensions, ...extensions },
8188
});
82-
this.filter.logIt(normalized, original);
83-
const { stack, ...extensions } = normalized;
84-
return {
85-
...extensions,
86-
stacktrace: stack.split('\n'),
87-
};
88-
}
89+
};
8990
}

src/core/graphql/graphql.options.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { getRegisteredScalars } from '~/common/scalars';
1515
import { ConfigService } from '../config/config.service';
1616
import { VersionService } from '../config/version.service';
1717
import { isGqlContext } from './gql-context.host';
18-
import { GraphqlErrorFormatter } from './graphql-error-formatter';
1918
import { GraphqlTracingPlugin } from './graphql-tracing.plugin';
2019

2120
type ServerContext = YogaDriverServerContext<'fastify'>;
@@ -27,7 +26,6 @@ export class GraphqlOptions implements GqlOptionsFactory {
2726
private readonly cache: CacheService,
2827
private readonly tracing: GraphqlTracingPlugin,
2928
private readonly versionService: VersionService,
30-
private readonly errorFormatter: GraphqlErrorFormatter,
3129
) {}
3230

3331
async createGqlOptions(): Promise<DriverConfig> {
@@ -53,6 +51,7 @@ export class GraphqlOptions implements GqlOptionsFactory {
5351
credentials: 'include',
5452
},
5553
context: this.context,
54+
maskedErrors: false, // Errors are formatted in plugin
5655
sortSchema: true,
5756
buildSchemaOptions: {
5857
// fieldMiddleware: [this.tracing.fieldMiddleware()],

0 commit comments

Comments
 (0)