Skip to content

Commit 9d6ae6e

Browse files
committed
checkpoint for error/http/context improvements
1 parent 3a453d7 commit 9d6ae6e

File tree

7 files changed

+188
-138
lines changed

7 files changed

+188
-138
lines changed

packages/server/src/ApolloServer.ts

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import type {
4040
ApolloServerOptions,
4141
DocumentStore,
4242
PersistedQueryOptions,
43-
HTTPGraphQLHead,
4443
ContextThunk,
4544
GraphQLRequestContext,
4645
} from './externalTypes/index.js';
@@ -52,7 +51,6 @@ import {
5251
} from './preventCsrf.js';
5352
import { APQ_CACHE_PREFIX, processGraphQLRequest } from './requestPipeline.js';
5453
import {
55-
badMethodErrorMessage,
5654
cloneObject,
5755
HeaderMap,
5856
newHTTPGraphQLHead,
@@ -976,16 +974,8 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
976974
);
977975
}
978976

979-
error.message = `Context creation failed: ${error.message}`;
980-
// If we explicitly provide an error code that isn't
981-
// INTERNAL_SERVER_ERROR, we'll treat it as a client error.
982-
const status =
983-
error instanceof GraphQLError &&
984-
error.extensions.code &&
985-
error.extensions.code !== ApolloServerErrorCode.INTERNAL_SERVER_ERROR
986-
? 400
987-
: 500;
988-
return this.errorResponse(error, newHTTPGraphQLHead(status));
977+
// FIXME doc that context errors always default to 500
978+
return this.errorResponse(error);
989979
}
990980

991981
return await runPotentiallyBatchedHttpQuery(
@@ -1012,38 +1002,30 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
10121002
`invalidRequestWasReceived hook threw: ${pluginError}`,
10131003
);
10141004
}
1015-
return this.errorResponse(
1016-
maybeError,
1017-
// Quite hacky, but beats putting more stuff on GraphQLError
1018-
// subclasses, maybe?
1019-
maybeError.message === badMethodErrorMessage
1020-
? {
1021-
status: 405,
1022-
headers: new HeaderMap([['allow', 'GET, POST']]),
1023-
}
1024-
: newHTTPGraphQLHead(400),
1025-
);
1005+
return this.errorResponse(maybeError);
10261006
}
10271007
return this.errorResponse(maybeError);
10281008
}
10291009
}
10301010

1031-
private errorResponse(
1032-
error: unknown,
1033-
httpGraphQLHead: HTTPGraphQLHead = newHTTPGraphQLHead(),
1034-
): HTTPGraphQLResponse {
1011+
private errorResponse(error: unknown): HTTPGraphQLResponse {
1012+
const { formattedErrors, httpFromErrors } = normalizeAndFormatErrors(
1013+
[error],
1014+
{
1015+
includeStacktraceInErrorResponses:
1016+
this.internals.includeStacktraceInErrorResponses,
1017+
formatError: this.internals.formatError,
1018+
},
1019+
);
1020+
10351021
return {
1036-
status: httpGraphQLHead.status ?? 500,
1022+
status: httpFromErrors.status ?? 500,
10371023
headers: new HeaderMap([
1038-
...httpGraphQLHead.headers,
1024+
...httpFromErrors.headers,
10391025
['content-type', 'application/json'],
10401026
]),
10411027
completeBody: prettyJSONStringify({
1042-
errors: normalizeAndFormatErrors([error], {
1043-
includeStacktraceInErrorResponses:
1044-
this.internals.includeStacktraceInErrorResponses,
1045-
formatError: this.internals.formatError,
1046-
}),
1028+
errors: formattedErrors,
10471029
}),
10481030
bodyChunks: null,
10491031
};

packages/server/src/__tests__/errors.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { unwrapResolverError } from '@apollo/server/errors';
33

44
import { normalizeAndFormatErrors } from '../errorNormalize.js';
55

6+
// FIXME write some tests of throwing http errors in fields and context
7+
68
describe('Errors', () => {
79
describe('normalizeAndFormatErrors', () => {
810
const message = 'message';
@@ -20,7 +22,7 @@ describe('Errors', () => {
2022
}),
2123
],
2224
{ includeStacktraceInErrorResponses: true },
23-
);
25+
).formattedErrors;
2426
expect(error.message).toEqual(message);
2527
expect(error.extensions?.key).toEqual(key);
2628
expect(error.extensions).not.toHaveProperty('exception'); // Removed in AS4
@@ -33,7 +35,7 @@ describe('Errors', () => {
3335
(thrown as any).key = key;
3436
const error = normalizeAndFormatErrors([
3537
new GraphQLError(thrown.message, { originalError: thrown }),
36-
])[0];
38+
]).formattedErrors[0];
3739
expect(error.message).toEqual(message);
3840
expect(error.extensions?.code).toEqual('INTERNAL_SERVER_ERROR');
3941
expect(error.extensions).not.toHaveProperty('exception'); // Removed in AS4
@@ -45,7 +47,7 @@ describe('Errors', () => {
4547
new GraphQLError(message, {
4648
extensions: { code, key },
4749
}),
48-
])[0];
50+
]).formattedErrors[0];
4951
expect(error.message).toEqual(message);
5052
expect(error.extensions?.key).toEqual(key);
5153
expect(error.extensions).not.toHaveProperty('exception'); // Removed in AS4
@@ -71,7 +73,9 @@ describe('Errors', () => {
7173
});
7274
it('Formats native Errors in a JSON-compatible way', () => {
7375
const error = new Error('Hello');
74-
const [formattedError] = normalizeAndFormatErrors([error]);
76+
const [formattedError] = normalizeAndFormatErrors([
77+
error,
78+
]).formattedErrors;
7579
expect(JSON.parse(JSON.stringify(formattedError)).message).toBe('Hello');
7680
});
7781

@@ -106,7 +110,7 @@ describe('Errors', () => {
106110
const errors = normalizeAndFormatErrors([thrown], {
107111
formatError,
108112
includeStacktraceInErrorResponses: true,
109-
});
113+
}).formattedErrors;
110114
expect(errors).toHaveLength(1);
111115
const [error] = errors;
112116
expect(error.extensions?.exception).toHaveProperty('stacktrace');
@@ -130,7 +134,7 @@ describe('Errors', () => {
130134
const errors = normalizeAndFormatErrors([thrown], {
131135
formatError,
132136
includeStacktraceInErrorResponses: false,
133-
});
137+
}).formattedErrors;
134138
expect(errors).toHaveLength(1);
135139
const [error] = errors;
136140
expect(error).toMatchInlineSnapshot(`

packages/server/src/__tests__/runQuery.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,10 @@ describe('request pipeline life-cycle hooks', () => {
830830
errors: expect.arrayContaining([
831831
expect.objectContaining({
832832
message: 'Syntax Error: Expected Name, found "}".',
833-
extensions: { code: 'GRAPHQL_PARSE_FAILED' },
833+
extensions: {
834+
code: 'GRAPHQL_PARSE_FAILED',
835+
http: { status: 400, headers: new Map() },
836+
},
834837
}),
835838
]),
836839
}),
@@ -852,7 +855,10 @@ describe('request pipeline life-cycle hooks', () => {
852855
expect.objectContaining({
853856
message:
854857
'Cannot query field "testStringWithParseError" on type "QueryType".',
855-
extensions: { code: 'GRAPHQL_VALIDATION_FAILED' },
858+
extensions: {
859+
code: 'GRAPHQL_VALIDATION_FAILED',
860+
http: { status: 400, headers: new Map() },
861+
},
856862
}),
857863
]),
858864
}),

packages/server/src/errorNormalize.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@ import {
66
GraphQLFormattedError,
77
} from 'graphql';
88
import { ApolloServerErrorCode } from './errors/index.js';
9+
import type { HTTPGraphQLHead } from './externalTypes/http.js';
10+
import {
11+
isHTTPGraphQLHead,
12+
mergeHTTPGraphQLHead,
13+
newHTTPGraphQLHead,
14+
} from './runHttpQuery.js';
915

1016
// This function accepts any value that were thrown and convert it to GraphQLFormattedError.
1117
// It also add default extensions.code and copy stack trace onto an extension if requested.
18+
// Additionally, it returns an `HTTPGraphQLHead` created from combining the values of any
19+
// `HTTPGraphqlHead` objects found on `extensions.http` (the behavior when multiple errors
20+
// set a status code or set the same header should be treated as undefined); these extensions
21+
// are removed from the formatted error.
22+
//
1223
// This function should not throw.
1324
export function normalizeAndFormatErrors(
1425
errors: ReadonlyArray<unknown>,
@@ -19,25 +30,33 @@ export function normalizeAndFormatErrors(
1930
) => GraphQLFormattedError;
2031
includeStacktraceInErrorResponses?: boolean;
2132
} = {},
22-
): Array<GraphQLFormattedError> {
33+
): {
34+
formattedErrors: Array<GraphQLFormattedError>;
35+
httpFromErrors: HTTPGraphQLHead;
36+
} {
2337
const formatError = options.formatError ?? ((error) => error);
24-
return errors.map((error) => {
25-
try {
26-
return formatError(enrichError(error), error);
27-
} catch (formattingError) {
28-
if (options.includeStacktraceInErrorResponses) {
29-
// includeStacktraceInErrorResponses is used in development
30-
// so it will be helpful to show errors thrown by formatError hooks in that mode
31-
return enrichError(formattingError);
32-
} else {
33-
// obscure error
34-
return {
35-
message: 'Internal server error',
36-
extensions: { code: ApolloServerErrorCode.INTERNAL_SERVER_ERROR },
37-
};
38+
const httpFromErrors = newHTTPGraphQLHead();
39+
40+
return {
41+
httpFromErrors,
42+
formattedErrors: errors.map((error) => {
43+
try {
44+
return formatError(enrichError(error), error);
45+
} catch (formattingError) {
46+
if (options.includeStacktraceInErrorResponses) {
47+
// includeStacktraceInErrorResponses is used in development
48+
// so it will be helpful to show errors thrown by formatError hooks in that mode
49+
return enrichError(formattingError);
50+
} else {
51+
// obscure error
52+
return {
53+
message: 'Internal server error',
54+
extensions: { code: ApolloServerErrorCode.INTERNAL_SERVER_ERROR },
55+
};
56+
}
3857
}
39-
}
40-
});
58+
}),
59+
};
4160

4261
function enrichError(maybeError: unknown): GraphQLFormattedError {
4362
const graphqlError = ensureGraphQLError(maybeError);
@@ -49,6 +68,11 @@ export function normalizeAndFormatErrors(
4968
ApolloServerErrorCode.INTERNAL_SERVER_ERROR,
5069
};
5170

71+
if (isHTTPGraphQLHead(extensions.http)) {
72+
mergeHTTPGraphQLHead(httpFromErrors, extensions.http);
73+
delete extensions.http;
74+
}
75+
5276
if (options.includeStacktraceInErrorResponses) {
5377
// Note that if ensureGraphQLError created graphqlError from an
5478
// originalError, graphqlError.stack will be the same as

packages/server/src/internalErrorClasses.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { GraphQLError, GraphQLErrorOptions } from 'graphql';
22
import { ApolloServerErrorCode } from './errors/index.js';
3+
import { HeaderMap, newHTTPGraphQLHead } from './runHttpQuery.js';
34

45
// These error classes are not part of Apollo Server's external API; the
56
// ApolloServerErrorCode enum is (exported from `@apollo/server/errors`).
@@ -23,7 +24,7 @@ export class SyntaxError extends GraphQLErrorWithCode {
2324
super(graphqlError.message, ApolloServerErrorCode.GRAPHQL_PARSE_FAILED, {
2425
source: graphqlError.source,
2526
positions: graphqlError.positions,
26-
extensions: graphqlError.extensions,
27+
extensions: { http: newHTTPGraphQLHead(400), ...graphqlError.extensions },
2728
originalError: graphqlError,
2829
});
2930
}
@@ -36,18 +37,34 @@ export class ValidationError extends GraphQLErrorWithCode {
3637
ApolloServerErrorCode.GRAPHQL_VALIDATION_FAILED,
3738
{
3839
nodes: graphqlError.nodes,
39-
extensions: graphqlError.extensions,
40+
extensions: {
41+
http: newHTTPGraphQLHead(400),
42+
...graphqlError.extensions,
43+
},
4044
originalError: graphqlError.originalError ?? graphqlError,
4145
},
4246
);
4347
}
4448
}
4549

50+
// Persisted query errors (especially "not found") need to be uncached, because
51+
// hopefully we're about to fill in the APQ cache and the same request will
52+
// succeed next time. We also want a 200 response to avoid any error handling
53+
// that may mask the contents of an error response. (Otherwise, the default
54+
// status code for a response with `errors` but no `data` (even null) is 400.)
55+
const getPersistedQueryErrorHttp = () => ({
56+
status: 200,
57+
headers: new HeaderMap([
58+
['cache-control', 'private, no-cache, must-revalidate'],
59+
]),
60+
});
61+
4662
export class PersistedQueryNotFoundError extends GraphQLErrorWithCode {
4763
constructor() {
4864
super(
4965
'PersistedQueryNotFound',
5066
ApolloServerErrorCode.PERSISTED_QUERY_NOT_FOUND,
67+
{ extensions: { http: getPersistedQueryErrorHttp() } },
5168
);
5269
}
5370
}
@@ -57,6 +74,11 @@ export class PersistedQueryNotSupportedError extends GraphQLErrorWithCode {
5774
super(
5875
'PersistedQueryNotSupported',
5976
ApolloServerErrorCode.PERSISTED_QUERY_NOT_SUPPORTED,
77+
// Not super clear why we need this to be uncached (makes sense for
78+
// PersistedQueryNotFoundError, because there we're about to fill the
79+
// cache and make the next copy of the same request succeed) but we've
80+
// been doing it for years so :shrug:
81+
{ extensions: { http: getPersistedQueryErrorHttp() } },
6082
);
6183
}
6284
}
@@ -79,14 +101,22 @@ export class OperationResolutionError extends GraphQLErrorWithCode {
79101
{
80102
nodes: graphqlError.nodes,
81103
originalError: graphqlError.originalError ?? graphqlError,
82-
extensions: graphqlError.extensions,
104+
extensions: {
105+
http: newHTTPGraphQLHead(400),
106+
...graphqlError.extensions,
107+
},
83108
},
84109
);
85110
}
86111
}
87112

88113
export class BadRequestError extends GraphQLErrorWithCode {
89-
constructor(message: string) {
90-
super(message, ApolloServerErrorCode.BAD_REQUEST);
114+
constructor(message: string, options?: GraphQLErrorOptions) {
115+
super(message, ApolloServerErrorCode.BAD_REQUEST, {
116+
...options,
117+
// Default to 400 status code, but caller can override. (If caller just
118+
// wants to override headers... well, they can't, sorry.)
119+
extensions: { http: newHTTPGraphQLHead(400), ...options?.extensions },
120+
});
91121
}
92122
}

0 commit comments

Comments
 (0)