Logo
Englika

Как подружиться с ошибками в GraphQL

Как подружиться с ошибками в GraphQL

Когда я первый раз увидел ошибку, возвращаемую сервером Apollo GraphQL я помню я подумал: "Что... это такое?". Вместо простого объекта error с message и code, я увидел что-то такое.

{
  "errors": [
    {
      "message": "Что-то случилось. Попробуйте еще раз позднее.",
      "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: Что-то случилось. Попробуйте еще раз позднее.",
          "  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
}

У меня возникло тогда столько вопросов. Будет ли stacktrace отображаться в production (успокойтесь, конечно же, нет)? Что значит path (просто путь до поля, где произошла ошибка; кстати, это очень полезно, потому что GraphQL позволяет делать несколько запросов одновременно с большим количеством вложенных полей, так что важно знать где именно произошла ошибка)? Почему структура ошибки выглядит такой... подробной? Можно ли сделать ошибки более простыми и хорошая ли это идея вообще?

Давайте разберемся, как работать с GraphQL ошибками, какие данные они должны содержать и как реализовать различные виды ошибок.

Какие данные должны быть в ошибках

Прежде, чем мы ответим на это, давайте выясним кто использует ошибки. Есть 2 типа людей, кто их использует.

Пользователи

Если пользователь сделал в приложении что-то некорректно, например, запросил несуществующие данные или попытался удалить что-то, не имея на это достаточно прав, сервер должен сообщить причину, почему запрос пользователя не был выполнен успешно и что он должен сделать, чтобы исправить это.

Хотя, к сожалению, похоже, что не все пользователи читают сообщения, зная тот факт, что некоторые люди, вместо того, чтобы нажимать по ссылке "Нажмите сюда, чтобы подтвердить свою почту", отвечают на это письмо, отправляя сообщение "Подтверждаю". Я недавно снова получил такое письмо :/

Сообщения об ошибках для пользователя должны быть как можно понятнее и короче и желательно содержать призыв к действию, чтобы он знал, что нужно сделать, чтобы исправить ее. Чем длиннее сообщение, тем меньше шанс, что он ее вообще прочитает, однако лучше сделать сообщение немного длиннее и сказать пользователю, как он может исправить данную проблему.

Примеры:

  • "Неверные аргументы" -> "Пожалуйста, введите свое имя"
  • "Некорректное имя" -> "Укажите более короткое имя, длинной до 40 символов"
  • "Ошибка при загрузке" -> "Вы можете загружать только изображения"
  • "Недостаточно прав" -> "Вы не можете получить доступ к этой компании. Либо выберите другую, либо попросите дать вам доступ к ней."

Разработчики

Разработчики используют уникальные коды ошибок, чтобы проверить, произошел ли какой-то конкретный случай. Например, если код invalid_data, то приложение должно показать пользователю ошибки рядом с каждым полем ввода, или если код token_is_expired, возникший в процессе восстановления пароля, тогда перенаправить пользователя на другую страницу, где он может попробовать восстановить свой пароль еще раз.

Если сервер пропустит код об ошибке (code), тогда разработчикам придется использовать сообщение (message), но это будет некорректно, потому что когда сообщение измениться, код на стороне клиента будет работать не так, как вы ожидали, до тех пор, пока разработчик заменит сообщение в условии.

Сервер может также возвращать дополнительное подробное сообщение об ошибке для разработчика, чтобы помочь ему разобраться, что он сделал не так. Обычно, это не то же самое сообщение, которое сервер возвращает пользователю.

В итоге, ошибка должна содержать message для пользователей и code для разработчиков.

Как реализовать ошибки в GraphQL

Существует 2 способа, как можно реализовать ошибки в GraphQL: возвращать их в массиве errors и возвращать их в результате. Давайте рассмотрим оба способа.

Стандартные ошибки

Наиболее популярный способ работы с ошибками в GraphQL, просто возвращать их в массиве errors.

{
  "errors": [
    {
      "message": "Не найдено",
      "path": ["myQuery"],
    }
  ]
}

Помните, что path показывает нам точный путь до поля, где произошла ошибка? Например, он может быть таким ["view", "sortedBy", "field", "id"], показывая нам, где именно произошла ошибка. Я уверен, что у вас нет сомнений о полезности этого поля. Единственное, что мы пропустили – это code, который должен быть указан для разработчиков. Куда мы должны его вставить?

Официальная документация GraphQL говорит: "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.".

GraphQL зарезервировал в ошибках специальное место для нас, разработчиков, называемое extensions, где мы можем вставить все, что угодно. Это позволит избежать конфликтов с будущими версиями GraphQL, в которых, например, может появится поле errors[0].code, которое будет перетирать наше.

{
  "errors": [
    {
      "message": "Не найдено",
      "path": ["myQuery"],
      "extensions": {
        "code": "not_found"
      }
    }
  ]
}

Мы также можем расширить ошибку, добавив сообщения для каждого поля ввода, в процессе валидации формы.

{
  "errors": [
    {
      "message": "Данные были введены неверно. Исправьте ошибки.",
      "extensions": {
        "code": "invalid_data",
        "data": {
          "email": {
            "code": "email_is_incorrect",
            "message": "Введите email в формате name@domain.com"
          },
          "name": {
            "code": "name_is_empty",
            "message": "Введите ваше имя"
          }
        }
      }
    }
  ]
}

Ошибки, используя схему GraphQL

Существует другой способ, как можно возвращать ошибки – использовать тип union в GraphQL.

Предположим, у нас есть запрос company, который возвращает компанию по его id. Если компания была удалена, мы хотим узнать кем именно. Если пользователь не имеет достаточных прав для получения доступа к этой компании, сервер должен вернуть клиенту соощение об ошибке. В итоге, у нас должны быть различные результаты для различных случаев.

Мы могли бы также реализовать эти ошибки, вставляя их в массив errors, но в этом случае у нас не будет типов на стороне клиента, и следовательно, скорее всего, мы совершим ошибку, читая поля из объекта с ошибкой, которых не существует. Кроме того, если company это field resolver, то такая ошибка приведет к ошибке всего запроса, что мы можем не хотеть. В этом случае, к нам на помощь приходят возможности GraphQL.

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='Вы не можете получить доступ к этой компании' />
}

Сравнение 2 подходов

Ключевой вопрос состоит в том, когда нужно использовать стандартные ошибки, а когда использовать схему GraphQL.

Стандартные ошибки дают нам следующие преимущества и ограничения:

  • Всплывают в дереве компонентов UI и будут использовать ближайший родительский компонент Suspense (если мы говорим про react). Также, если такие ошибки возникнут в field resolver, то они будут распространяться вверх по дереву GraphQL до тех пор, пока не встретиться первое поле, которое может принимать значение null.
  • Могут быть общими и возникать везде, не только в одном query/mutation.
  • Должны имееть одинаковую структуру, обычно только message для пользователей и code для разработчиков.
  • Должны быть отображены в UI одинаково.

Ошибки, используя схему GraphQL:

  • Ошибки, возникшие во вложенных полях не приведут к ошибке всего запроса.
  • Должны относиться только к одному query/mutation. Они не должны быть общими, такие как внутренние ошибки сервера.
  • Обычно, возвращают различные данные.
  • Каждая ошибка должна быть отображена в UI по-разному.

Давайте рассмотрим несколько примеров, где лучше использовать стандартные ошибки, а где использовать схему GraphQL.

Как реализовать различные ошибки

Внутренние ошибки

Причины таких ошибок могут быть различными: некорректный SQL-запрос, что-то произошло на стороне cloud storage во время загрузки нового изображения, ваш алгоритм шифрования не смог дешифровать зашифрованный текст и т.д. В любом случае, это наша вина, что такие ошибки произошли, даже если они возникли на стороне стороннего сервиса, такого как cloud storage, потому что мы их не обработали.

Внутренние ошибки могут неожиданно возникнуть где угодно на нашем сервере. На production, обычно, мы не хотим показывать клиенту оригинальное сообщение об ошибке, потому что, иначе, пользователь сможет увидеть имя нашей таблицы в базе данных, возможно точный SQL запрос, который не удалось выполнить, или некоторые приватные/чувствительные данные.

Кто знает, на сколько подробными будут ошибки, произошедшие в библиотеке, которую мы используем. Вы, скорее всего, не будете проверять исходный код всех используемых библиотек каждый раз, как вы их обновляете (обычно это делается только один раз перед началом их использования), но в идеальном мире мы определенно должны делать это.

Поэтому GraphQL сервер должен отловить любую внутреннюю ошибку и вернуть оригинальное сообщение об ошибке только в среде development. В production лучше возвращать такое сообщение, как "Что-то случилось. Попробуйте еще раз позднее.".

Давайте реализуем это. Во-первых, нам нужно понять, кто вызывал ошибку, мы (этот тип ошибок должен быть показан пользователю) или сторонний сервис/библиотека, которую мы используем (эти ошибки должны быть заменены). Чтобы сделать это, мы создадим свой собственный класс Error, который будем использовать.

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;

Затем мы должны отлавливать все ошибки, возникшие в процессе выполнения queries/mutations и проверять их. Я воспользуюсь глобальными middlewares в type-graphql, чтобы реализовать это. Есть также функция formatError в Apollo Server, которую можно использовать для этих целей.

const errorHandlingMiddleware: MiddlewareFn = async (_, next) => {
  try {
    return await next();
  } catch (e) {
    if (e instanceof AppError) {
      throw e;
    } else {
      // Здесь можно сохранить оригинальное сообщение об ошибке в базу данных
      throw new AppError({
        code: 'unexpected',
        message: 'Что-то случилось. Попробуйте еще раз позднее.',
        extensions: process.env.NODE_ENV === 'development'
          ? { originalMessage: e instanceof Error ? e.message : null }
          : undefined
      });
    }
  }
};

const schema = await buildSchema({
  resolvers: [MyResolver],
  globalMiddlewares: [errorHandlingMiddleware],
});

Теперь, каждый раз, как наш сервер выполняет query/mutation и возникает ошибка, он проверяет, является ли ошибка нашей. Если это так, сервер выбрасывает ее, иначе выбрасывает ошибку "Что-то случилось. Попробуйте еще раз позднее."

Ошибки при валидации

Если пользователь допустил ошибки в процессе заполнения формы в приложении, сервер должен сообщить клиенту какие именно поля ввода были заполнены некорректно. Если было допущены ошибки в N различных полях ввода, сервер должен вернуть N сообщений об ошибках, которые фронтент разработчики должны отобразить рядом с каждым полем в форме.

Как вы думаете, какой вид ошибок вы должны использовать для этого: стандартные или схему? Я слышал, что некоторые реализуют ошибки, возникшие при валидации, используя схему GraphQL, но я не думаю, что это хорошая идея. В большинстве случаев, ошибки при валидации могут возникнуть в mutations, где мы хотим создать или обновить некоторую сущность. Да, каждый mutation имеет свои собственные ошибки, но это не значит, что этого достаточно, чтобы принять решение реализовывать такие ошибки, используя схему GraphQL.

Я думаю, что в процессе принятия решения о том, как должны быть реализованы ошибки, мы должны задать себе следующий вопрос: должна ли структура этих ошибок быть различной (с различными полями) и нужно ли отображать их в UI по-разному? Если да, то используйте схему GraphQL. Если нет, скорее всего лучше использовать стандартные ошибки.

Ошибки при валидации имеют одинаковый формат и я считаю, что они должны быть реализованы без схемы GraphQL. Я продублирую этот код, чтобы вам не пришлось скролить вверх, чтобы вспомнить его.

{
  "errors": [
    {
      "message": "Данные были введены неверно. Исправьте ошибки.",
      "extensions": {
        "code": "invalid_data",
        "data": {
          "email": {
            "code": "email_is_incorrect",
            "message": "Введите email в формате name@domain.com"
          },
          "name": {
            "code": "name_is_empty",
            "message": "Введите ваше имя"
          }
        }
      }
    }
  ]
}

На стороне клиента вы можете написать простую функцию, которая принимает ошибку, читает объект errors[0].extensions.data, если code invalid_data и сохраняет в форму все сообщения об ошибках.

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);
  }
};

И затем просто запускаем эту функцию в mutations.

commit({
  variables: { input: form.values.getAll() },
  onError: (error) => handleFormErrors(form, error), // Лишь 1 строка кода
  onCompleted: () => message.success('Обновлено'),
});

Это все. Лишь 1 строка кода. Подумайте, что вам пришлось бы написать, если бы вы решили реализовать эти ошибки, используя схему GraphQL.

Вторая причина, почему мы не должны использовать схему GraphQL для таких ошибок, это то, что оптимистичные обновления (optimistic updates) не могут быть отменены. Поэтому, это определенно плохая идея реализовывать ошибки при валидации (ошибки в мутациях), используя схему GraphQL.

Другие ошибки

Существует множество других ошибок, которые могут возникнуть: не найдено, недостаточно прав, и др. Большинство из них должно быть реализовано, используя стандартные ошибки, но если кто-то реализовал ошибку определенным способом, то это не значит, что то же самое подходит и для вас.

Я предпочитаю использовать стандартные ошибки для всего, кроме случаев, когда они должны быть отображены в UI по-разному или они не должны приводить к ошибке всего запроса. Не гонитесь за модой.

Вдохновлено

Похожие статьи

Как получить N строк для каждой группы в SQL

Давайте предположим, что вы разрабатываете главную страницу в интернет-магазине. На этой странице должны отображаться категории товаров с 10 товарами в каждой. Как сделать запрос к базе данных? Какие индексы нужно создать, чтобы ускорить выполнение...

Как получить N строк для каждой группы в SQL