When I saw an error returned by the Apollo GraphQL server for the first time I remember I thought: "What... is that?". Instead of a simple error object with a message and code, I saw something like this.
{
"errors": [
{
"message": "Something happened. Try again later.",
"locations": [
{
"line": 19,
"column": 9
},
{
"line": 212,
"column": 7
},
{
"line": 175,
"column": 7
},
{
"line": 101,
"column": 7
},
{
"line": 259,
"column": 7
}
],
"path": [
"view",
"sortedBy",
"field",
"id"
],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"stacktrace": [
"GraphQLError: Something happened. Try again later.",
" at new AppError (<path>/src/utils/errors/AppError.ts:12:5)",
" at new UnexpectedError (<path>/src/utils/errors/UnexpectedError.ts:6:5)",
" at errorHandlingMiddleware (<path>/src/utils/server/createServer.ts:60:13)",
" at processTicksAndRejections (node:internal/process/task_queues:95:5)",
" at async dispatchHandler (<path>/node_modules/type-graphql/build/cjs/resolvers/helpers.js:75:24)",
" at async <path>/node_modules/@graphql-tools/executor/cjs/execution/promiseForObject.js:18:35",
" at async Promise.all (index 1)"
]
}
}
],
"data": null
}
I had so many questions. Would the stacktrace really be shown in production (calm down, of course not)? What does the path mean (just a path to the field where an error is occured; btw, it's very useful because GraphQL allows to make multiple requests at once with a lot of nested fields, so it's important to know where exactly the error is occured)? Why the structure of an error looks so... comprehensive? Can I make errors look simplier and whether it's a good idea at all?
Let's to figure out how to deal with GraphQL errors, what data they should contain and how to implement different types of errors.
What data must be in errors
Before answering to it, let's figure out who uses the errors. There are 2 types of people who use it.
Users
If a user has done something wrong in the app, for example, has requested a non-existent data or has tried to delete something without having enough pemission for that, the server should send the reason why his request has not been completed successfully and what he should do to fix it.
Although, seems that, unfortunately, not all the users read the messages, knowing the fact that some people instead of clicking to the link "Click here to confirm your email" replying to that email by sending the text "I confirm". I recently received such a letter once again :/
The error messages for a user should be as clear and concise as possible and preferably contain call to action, so he will know what to do to fix it. The longer the message, the less likely he will read it at all, but it's better to make a message a little longer to say the user how he can fix that problem.
Examples:
- "Bad arguments" -> "Please enter your name"
- "Incorrect name" -> "Give a shorter name, up to 40 characters"
- "Failed to upload" -> "You can only upload images"
- "Not enough permissions" -> "You cannot get access to this company. Either choose another one, or ask someone to give you access to it."
Developers
Developers use unique error codes to check whether a specific case has happened. For example, if the code is invalid_data then the app should display error messages to a user next to each input, or if the code is token_is_expired that arose during the password recovering then redirect a user to another page where he can try to reset his password again.
If the server skips the error code, then developers will have to use a message, but it will be completely wrong because when the message will be changed, the code on the client side will stop working correctly until a developer replace that message in the condition.
Server may also returns an additional detailed error message for a developer to help him figure out what he has done wrong. Usually, it's not the same message that the server returns to a user.
So an error must contain a message for users and a code for developers.
How to implement GraphQL errors
There are 2 ways how errors can be implemented in GraphQL: put them into an array of errors and put them into a result. Let's take a look at both ways.
Standard errors
The most common way to deal with errors in the GraphQL, just put them into an array of errors.
{
"errors": [
{
"message": "Not found",
"path": ["myQuery"],
}
]
}
Do you remember that the path shows us the exact path to a field where an error has been occured? For example, it might be like this ["view", "sortedBy", "field", "id"], showing us where exactly the error has happened. I'm sure there are no questions about the usefulness of that. The only missing thing is the code that must be specified for developers. Where should we put it?
The official GraphQL documentation says: "GraphQL services should not provide any additional entries to the error format since they could conflict with additional entries that may be added in future versions of this specification. Previous versions of this spec did not describe the extensions entry for error formatting. While non-specified entries are not violations, they are still discouraged.".
So GraphQL has reserved a special area in errors for us, developers, called extensions, where we can put everything we want. It will avoid conflicts with future versions of GraphQL where, for example, the field errors[0].code may be appeared, which will redefine ours.
{
"errors": [
{
"message": "Not found",
"path": ["myQuery"],
"extensions": {
"code": "not_found"
}
}
]
}
We also can extend an error by adding messages for each input during the form validation.
{
"errors": [
{
"message": "The data has been entered incorrectly. Fix the mistakes.",
"extensions": {
"code": "invalid_data",
"data": {
"email": {
"code": "email_is_incorrect",
"message": "Enter the email like this name@domain.com"
},
"name": {
"code": "name_is_empty",
"message": "Enter your name"
}
}
}
}
]
}
Errors using the GraphQL schema
There is another approach that allows us to return errors to a client – using GraphQL union type.
Let's say we have the company query that returns a company by its id. If a company has been deleted we want to know by whom. If a user doesn't have enough permissions to access this company, the server should only return an error message to a client. So we have different results for different cases.
We could also implement these errors by placing them in an array of errors, but in this case we won't have types on the client side, and therefore it's more likely that we'll make a mistake by reading fields from the error object that don't exist. Additionally, if the company is a field resolver, such an error will cause the entire query to fail what we might not want. In this case, GraphQL features come to the rescue.
union CompanyResult = Company | DeletedCompany | PermissionDenied
type Company {
id: String!
name: String!
}
type DeletedCompany {
id: String!
deletedBy: User!
}
type PermissionDenied {
id: String!
message: String!
}
type Query {
company(id: String!): CompanyResult
}
query CompanyQuery($id: String!) {
company(id: $id) {
__typename
... on Company {
name
}
... on DeletedCompany {
deletedBy
}
... on PermissionDenied
message
}
}
}
switch (company.__typename) {
case 'Company':
return <Company name={company.name} />
case 'DeletedCompany':
return <DeletedCompany deletedBy={company.deletedBy} />
case 'PermissionDenied':
return <PermissionDenied message={company.message} />
default:
return <Error message='You cannot access to this company' />
}
Comparision of 2 approaches
The key question is when it is more appropriate to use standard errors, and when to use GraphQL schema.
Standard errors give us the following advantages and constraints:
- Will be bubble up in the UI component tree and will use the closest parent Suspense component (if we are talking about react). Also, if such errors will occur in a field resolver, they propagate upwards in the GraphQL tree till the first nullable field occurs.
- Might be common and occur everywhere, not just in a single query/mutation.
- Should have the same structure, usually only a message for users and code for developers.
- Should be displayed in the UI in the same way.
Errors using the GraphQL schema:
- Errors occured in nested fields won't cause the entire query to fail.
- Should only be relevant to one query/mutation. They should not be common, like internal server errors.
- Usually return different data.
- Each error should be displayed differently in the UI.
Let's look at few examples where it's better use standard errors and where use the GraphQL schema.
How to implement different errors
Internal errors
The reason of such errors can be different: an SQL query is wrong, a cloud storage has been failed during uploading a new image, your crypto algorithm couldn't decipher an encrypted text, etc. Anyway, it's our fault that these mistakes happen, even if they were caused by a third-party service like a cloud storage because we didn't catch them.
Internal errors can unexpectedly occur anywhere on our server. In production, usually we don't want to show a client the original error message because othewise the user can see the name of our table in the database, might be an exact SQL query that has been failed, or some sensitive data.
Who knows how detailed are the errors raised by libraries which we use. Most likely, you won't check the source code of all used libraries each time you update them (usually we do it only once before start using it), but in ideal world we definately should do that.
So our GraphQL server should catch any internal error and return an original message in the response only in the development environment. In production it's better to return the message like "Something happened. Try again later.".
Let's implement it. First, we need to figure out who caused the error, us (this type of errors should be shown to a user) or a third-party service/library we are using (these errors should be replaced). To do that, we'll create our own Error class and use it.
import { GraphQLError, GraphQLErrorExtensions } from 'graphql';
interface AppErrorOptions {
code: string;
message: string;
extensions?: GraphQLErrorExtensions;
}
class AppError extends GraphQLError {
public constructor(options: AppErrorOptions) {
const { code, message, extensions } = options;
super(message, {
extensions: {
code,
...extensions,
},
});
}
}
export default AppError;
Then we have to catch all the errors occured during the execution of queries/mutations and check them out. I'll use global middlewares in type-graphql to implement it. There is also formatError function in Apollo Server which can be used for that purpose.
const errorHandlingMiddleware: MiddlewareFn = async (_, next) => {
try {
return await next();
} catch (e) {
if (e instanceof AppError) {
throw e;
} else {
// You can save the original error message to the database here
throw new AppError({
code: 'unexpected',
message: 'Something happened. Try again later.',
extensions: process.env.NODE_ENV === 'development'
? { originalMessage: e instanceof Error ? e.message : null }
: undefined
});
}
}
};
const schema = await buildSchema({
resolvers: [MyResolver],
globalMiddlewares: [errorHandlingMiddleware],
});
Now every time our server executes a query/mutation and an error occurs, it checks whether the error is ours. If so, the server throws it, otherwise throws the error "Something happened. Try again later.".
Validation errors
If a user made mistakes during filling out a form in the app, the server should say to a client what inputs were entered wrong. If there were mistakes in N different inputs, the server should send N error messages which front-end developers can display next to each input in that form.
What do you think, which type of errors we should use for that: standard or schema? I've heard that someone implement validation errors using GraphQL schema, but I don't think it's a good idea. In most cases, validation errors might be happen in mutations when we want to create or update some entity. Yes, each mutation has his own validation errors, but it doesn't mean that it's more than enough to make a decision to implement such errors using GraphQL schema.
I think during making a decision how the errors should be implemented, we should ask yourself the following question: is the structure of these errors should be different (with different fields) and displayed in the UI different ways? If so, use GraphQL schema. If not, most likely it's better to use standard errors.
Validation errors have the same format and I think should be implemented without GraphQL schema. I'll duplicate this code, so you won't need to scroll up to remember it.
{
"errors": [
{
"message": "The data has been entered incorrectly. Fix the mistakes.",
"extensions": {
"code": "invalid_data",
"data": {
"email": {
"code": "email_is_incorrect",
"message": "Enter the email like this name@domain.com"
},
"name": {
"code": "name_is_empty",
"message": "Enter your name"
}
}
}
}
]
}
On the client side you can write a simple function which receive an error, read the object errors[0].extensions.data if the code is invalid_data, and save all the error messages inside the form.
const handleFormErrors = (form: Form, error: Error): void => {
const { code, data } = e.source.errors[0].extensions;
if (code === 'invalid_data') {
Object.entries(data).forEach(([fieldName, { message }]) => {
form.errors.set(fieldName, message);
});
} else {
form.errors.set('_error', e.source.errors[0].message);
}
};
And then just call this function in mutations.
commit({
variables: { input: form.values.getAll() },
onError: (error) => handleFormErrors(form, error), // Just 1 line of code
onCompleted: () => message.success('Updated'),
});
That's all. Just 1 line of code. Think about what you should write if you decided to implement these errors using the GraphQL schema.
The second reason why we shouldn't use GraphQL schema for such errors is that optimistic updates cannot be roll back. So it's definitely a bad idea to implement validation errors (errors in mutations) using the GraphQL schema.
Other errors
There are many other errors that might be happen: not found, permission denied, etc. Most of them should be implemented using standard errors, but if someone implement an error one way it doesn't mean that it's appropriate for you.
I'll prefer to use standard errors for everything until the cases when they should be displayed in the UI in different ways or they shouldn't cause the entire query to fail. Don't chase fashion trends.