-
-
Notifications
You must be signed in to change notification settings - Fork 1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improved error handling in GraphQL APIs #437
Comments
I got 100% positive feedback on this proposal on the Vendure slack & via Twitter. I'll start a proof-of-concept implementation now. A bit more research follows: Further reading
Some key points: Error typesYou can think of 2 "types" of errors:
The first kind we can continue to handle in the GraphQL "errors" array. There's no logical relationship between a particular mutation and some random InternalServerError, so it makes no sense to encode this in the schema. The second kind is the one we are interested in encoding into the schema. Handling in the clientOnce we encode all expected response types into the schema, the client can then handle each appropriately using the fragment syntax. Since we will use a common interface as the basis of all errors, clients can always select this interface to ensure the error is caught, even if the specific implementation types change over time. Alternative solution: error fields in resultThe Apollo docs recommend adding extra fields to mutation responses:
The Python GraphQL client Ariadne recommends something similar type_def = """
type Mutation {
login(username: String!, password: String!) {
error: String
user: User
}
}
""" A criticism of this approach is given in the "Where art thou?" blog post:
Another downside of this approach is that we don't get the same level of type-safety. With the union types, we can do things like exhaustiveness checks to make sure we always handle all possible result types, and we get static typing on particular properties that may be defined on particular error results. |
Proof of conceptI've put together a proof-of-concept for the ErrorResult interface This is the interface implemented by all error results: enum ErrorCode {
UnknownError
}
interface ErrorResult {
code: ErrorCode!
message: String!
} Concrete error result type Mutation {
"Create a new Asset"
createAssets(input: [CreateAssetInput!]!): [CreateAssetResult!]!
# ...
}
type MimeTypeError implements ErrorResult {
code: ErrorCode!
message: String!
fileName: String!
mimeType: String!
}
union CreateAssetResult = Asset | MimeTypeError Note that the enum ErrorCode {
UnknownError
MimeTypeError
} Code generation
The generated code looks like this: // tslint:disable
/** This file was generated by the graphql-errors-plugin, which is part of the "codegen" npm script. */
import { ErrorCode } from '@vendure/common/lib/generated-types';
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
DateTime: any;
JSON: any;
Upload: any;
};
export class ErrorResult {
readonly __typename: string;
readonly code: ErrorCode;
message: Scalars['String'];
}
export class MimeTypeError extends ErrorResult {
readonly __typename = 'MimeTypeError';
readonly code = ErrorCode.MimeTypeError;
constructor(
public message: Scalars['String'],
public fileName: Scalars['String'],
public mimeType: Scalars['String'],
) {
super();
}
}
const errorTypeNames = new Set(['MimeTypeError']);
export function isGraphQLError(
input: any,
): input is import('@vendure/common/lib/generated-types').ErrorResult {
return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
}
export const adminErrorOperationTypeResolvers = {
CreateAssetResult: {
__resolveType(value: any) {
return isGraphQLError(value) ? (value as any).__typename : 'Asset';
},
},
}; Returning error results from services private async createAssetInternal(
ctx: RequestContext,
stream: Stream,
filename: string,
mimetype: string,
): Promise<CreateAssetResult> {
const { assetOptions } = this.configService;
if (!this.validateMimeType(mimetype)) {
// throw new UserInputError('error.mime-type-not-permitted', { mimetype });
return new MimeTypeError('error.mime-type-not-permitted', filename, mimetype);
}
// ... create Asset
} And we can correctly discriminate between the actual Asset and any error results using the generated /**
* Create an Asset based on a file uploaded via the GraphQL API.
*/
async create(ctx: RequestContext, input: CreateAssetInput): Promise<CreateAssetResult> {
const { createReadStream, filename, mimetype } = await input.file;
const stream = createReadStream();
const result = await this.createAssetInternal(ctx, stream, filename, mimetype);
if (isGraphQLError(result)) {
return result;
}
this.eventBus.publish(new AssetEvent(ctx, result, 'created'));
return result;
} Next stepsNext I will go through all points where errors are thrown, and determine whether they should stay as thrown errors (e.g. InternalServerError) or whether they should be returned as an ErrorResult with the resulting change to the return type of the GraphQL operation (e.g. with UserInputError). An open question is how to handle translations of error messages. I am debating whether or not it makes sense to even do i18n on server error messages. It might make more sense to just return a code and let it be translated on the client. Not decided yet so I'm going to leave this to the end. |
This commit introduces a new graphql-code-generator plugin which generates error classes and supporting code to enable error result union types as described in #437
Relates to #437 feat(core): Improved error handling for remaining admin mutations feat(core): Improved error handling for admin promotion mutations feat(core): Improved error handling for admin order mutations feat(core): Improved error handling for global settings mutations feat(core): Improved error handling for customer mutations feat(core): Improved error handling auth mutations
I have been following this approach. The problem for me so far is that the resolver throws an "Unexpected error value" with the custom error as key that leads to the graphql query throwing instead of returning the error as the union result
|
Is your feature request related to a problem? Please describe.
Current error handling is pretty basic: throw an error from anywhere in the stack. This results in the error populating the GraphQL
errors
array in the response.There are a few problems with this:
createAssets
, if uploading 5 images and 1 fails, there's no way to return 4 good results and 1 error result.Describe the solution you'd like
This talk describes a good solution that solves all of the above: https://www.youtube.com/watch?v=RDNTP66oY2o
The basic idea is to define results as a union of happy-path and error path results, so that all the "expected" errors are encoded into the schema and can explicitly queried for and handled by the client.
Example: the
createAssets
mutation:This would then be executed on the client with a mutation like:
Challenges
This would require some quite significant re-work of the schema, as a new result union will need to be defined for the majority of operations. Also to keep the resolvers type safe, we'd want to always use the generated return types, rather than the entities as is currently the case for most of the resolvers. This part would depend on #410 being solved I think.
The text was updated successfully, but these errors were encountered: