Search code examples
error-handlinggraphqlmutation

Proper error handling when performing multiple mutations in graphql


Given the following GraphQL mutations:

type Mutation {
  updateUser(id: ID!, newEmail: String!): User
  updatePost(id: ID!, newTitle: String!): Post
}

The Apollo docs state that it's totally possible to perform multiple mutations in one request, say

mutation($userId: ID!, $newEmail: String!, $postId: ID!, $newTitle: String!) {
  updateUser(id: $userId, newEmail: $newEmail) {
    id
    email
  }
  updatePost(id: $postId, newTitle: $newTitle) {
    id
    title
  }
}

1. Does anyone actually do this? And if you don't do this explicitly, will batching cause this kind of mutation merging?

2. If you perform run multiple things within on mutation, how would you handle errors properly?

I've seen a bunch of people recommending to throw errors on the server so that the server would respond with something that looks like this:

{
  errors: [
    {
      statusCode: 422,
      error: 'Unprocessable Entity'
      path: [
        'updateUser'
      ],
      message: {
        message: 'Validation failed',
        fields: {
          newEmail: 'The new email is not a valid email address.'
        }
      },
    },
    {
      statusCode: 422,
      error: 'Unprocessable Entity'
      path: [
        'updatePost'
      ],
      message: {
        message: 'Validation failed',
        fields: {
          newTitle: 'The given title is too short.'
        }
      },
    }
  ],
  data: {
    updateUser: null,
    updatePost: null,
  }
}

But how do I know which error belongs to which mutation? We can't assume, that the first error in the errors array belongs to the first mutation, because if updateUser succeeds, the array would simple contain one entry. Would I then have to iterate over all errors and check if the path matches my mutation name? :D

Another approach is to include the error in a dedicated response type, say UpdateUserResponse and UpdatePostResponse. This approach enables me to correctly address errors.

type UpdateUserResponse {
  error: Error
  user: User
}

type UpdatePostResponse {
  error: Error
  post: Post
}

But I have a feeling that this will bloat my schema quite a lot.


Solution

  • In short, yes, if you include multiple top-level mutation fields, utilize the path property on the errors to determine which mutation failed. Just be aware that if an error occurs deeper in your graph (on some child field instead of the root-level field), the path will reflect that field. That is, an execution error that occurs while resolving the title field would result in a path of updatePost.title.

    Returning errors as part of the data is an equally valid option. There's other benefits to this approach to:

    • Errors sent like this can include additional meta data (a "code" property, information about specific input fields that may have generated the error, etc.). While this same information can be sent through the errors array, making it part of your schema means that clients will be aware of the structure of these error objects. This is particularly important for clients written in typed languages where client code is often generated from the schema.
    • Returning client errors this way lets you draw a clear distinction between user errors that should be made visible to the user (wrong credentials, user already exists, etc.) and something actually going wrong with either the client or server code (in which case, at best, we show some generic messaging).
    • Creating a "payload" object like this lets you append additional fields in the future without breaking your schema.

    A third alternative is to utilize unions in a similar fashion:

    type Mutation {
      updateUser(id: ID!, newEmail: String!): UpdateUserPayload!
    }
    
    union UpdateUserPayload = User | Error
    

    This enables clients to use fragments and the __typename field to distinguish between successful and failed mutations:

    mutation($userId: ID!, $newEmail: String!) {
      updateUser(id: $userId, newEmail: $newEmail) {
        __typename
        ... on User {
          id
          email
        }
        ... on Error {
          message
          code
        }
      }
    }
    

    You can get even create specific types for each kind of error, allowing you to omit any sort of "code" field:

    union UpdateUserPayload = User | EmailExistsError | EmailInvalidError
    

    There's no right or wrong answer here. While there are advantages to each approach, which one you take comes ultimately comes down to preference.