В любом приложении возникают ошибки, и в вашим GraphQL API они тоже будут. Се ля ви.
Как работать с ошибками в GraphQL? К чему необходимо быть готовым клиентам вашего АПИ? Как лучше возвращать ошибки клиенту? Да и как вообще они возвращаются в GraphQL? В этот статье мы разберем как работать с ошибками в GraphQL.
Для начала давайте бегло посмотрим какие ошибки могут возникать и сразу разобьем их на группы:
Если произошла фатальная
ошибка, то сервер возвращает 500 код. Это как обычно.
Но вот что необычное в GraphQL, так если произошла любая другая ошибка сервер возвращает код 200. Обычно бывалые REST API разработчики на этом моменте хотят выпрыгнуть из окна. Никаких вам 401, 403, 404 и прочих кодов не будет.
Сделали это так, потому что GraphQL по спецификации не привязан ни к какому протоколу. Вы можете гонять GraphQL-запросы через websockets, ssh, telnet ну и обычный http. Коль нет жесткой привязки к протоколу, то ошибки все унесли в тело ответа.
Вот так выглядит ответ от GraphQL по спецификации:
{
data: {}, // для возврата данных
errors: [...], // для возврата ошибок, массив между прочим 😳
extensions: {}, // объект для пользовательских данных, сюда пихайте что хотите
// другие ключи запрещены по спеке!
}
Первое что бросается в глаза так это то, что GraphQL возвращает массив ошибок. Wow! Т.к. запрос может быть сложный с запросом кучи ресурсов, то GraphQL может вернуть вам часть данных, а на оставшуюся часть вернуть ошибки. И это хорошо, пол ответа лучше, чем ничего.
Фатальная ошибка чаще всего имеет следующий вид — 500 Internal Server Error
. Возникает обычно если кончилась память, забыли установить пакет, совершили грубую синтаксическую ошибку в коде. Да много еще чего. При этом дело не доходит до обработки GraphQL-запроса. И здесь резонно вернуть 500 ошибку.
Нет работы GraphQL, нет кода 200.
Фронтендеры обычно это дело должны обрабатывать на уровне своего Network Layer’a. Получили 500, значит где-то косячнулись бэкендеры с админами.
Сервер получил запрос и делегировал его в пакет graphql. Перед тем как GraphQL-запрос будет выполняться он проходит парсинг и валидацию. Если кривой запрос, то никакие resolve-методы вызваны не будут и тупо будет возвращена ошибка:
{
errors: [
{
message: 'Cannot query field "wrong" on type "Query".',
locations: [{ line: 3, column: 11 }],
},
],
}
// или например такая
{
errors: [
{
message: 'Variable "$q" of required type "String!" was not provided.',
locations: [{ line: 2, column: 16 }],
},
],
}
При этом сервер вернет статус 200. При коде 200, ошибка обычно на стороне фронтендера. Но и бекендер может быть к этому причастен, если взял и удалил из схемы какое-то поле. В таком случае все старые работающие приложения теперь стали отправлять невалидные запросы.
Если запрос прошел парсинг и валидацию, то он начинает выполняться и вызывать resolve-методы вашей схемы согласно присланному GraphQL-запросу. И если вдруг внутри resolve-метода вываливается Exception (throw new Error()
), неважно явно вы его выбросили, или он прилетел из недр чужих пакетов. То происходит следующая магия:
null
errors
Хорошо это понять можно на примере следующего кода:
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
search: {
args: {
q: { type: GraphQLString },
},
resolve: (_, args) => {
if (!args.q) throw new Error('missing q');
return { text: args.q };
},
type: new GraphQLObjectType({
name: 'Record',
fields: {
text: {
type: GraphQLString,
resolve: source => source.text,
},
},
}),
},
},
}),
});
const res = await graphql({
schema,
source: `
query {
s1: search(q: "ok") { text }
s2: search { text }
s3: search(q: "good") { text }
}
`,
});
Ответ от сервера будет получен следующий:
{
errors: [
{ message: 'missing q', locations: [{ line: 4, column: 11 }], path: ['s2'] }
],
data: { s1: { text: 'ok' }, s2: null, s3: { text: 'good' } },
}
Поле s1
возвращает полный результат. В s2
была выброшена ошибка, поэтому оно стало null
и в массив errors
добавилась ошибка. И дальше поле s3
тоже без проблем вернулось.
Т.е. получается на тех местах, где была выброшена ошибка возвращается null
и пишется ошибка в массив. А вся остальная часть запроса продолжает выполняться как ни в чем не бывало. Вот такой вот он добрый GraphQL, хоть что-нибудь да вернет.
Точно также работает, если бэкендер вернул данные неправильного типа в resolve-методе. GraphQL не позволяет вернуть “левые данные” в data
.
Вот пример, когда мы по схеме должны вернуть массив строк, но второй элемент не является строкой. Вместо “левого” значения, он вернет null
и при этом добавит ошибку в массив:
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
ooops: {
type: new GraphQLList(GraphQLString),
resolve: () => ['ok', { hey: 'wrong non String value' }],
},
},
}),
});
const res = await graphql(schema, `query { ooops }`);
expect(res).toEqual({
errors: [
{
message: 'String cannot represent value: { hey: "wrong non String value" }',
locations: [{ line: 3, column: 11 }],
path: ['ooops', 1],
},
],
data: { ooops: ['ok', null] },
});
Также спецификация GraphQL позволяет передать дополнительные данные вместе с ошибкой через проперти extensions
. Давайте создадим объект ошибки и присвоим ему два проперти extensions
и someOtherData
:
new GraphQLObjectType({
name: 'Query',
fields: {
search: {
resolve: () => {
const e: any = new Error('Some error');
e.extensions = { a: 1, b: 2 }; // will be passed in GraphQL-response
e.someOtherData = { c: 3, d: 4 }; // will be omitted
throw e;
},
type: GraphQLString,
},
},
});
На выходе в GraphQL-ответе мы получим следующие данные (extensions
будет передан, а все другие проперти из объекта ошибки будут опущены, например не будет someOtherData
из нашего примера):
{
errors: [
{
message: 'Some error',
locations: [{ line: 1, column: 9 }],
path: ['search'],
extensions: { a: 1, b: 2 },
},
],
data: { search: null },
}
Такой механизм позволяет передать клиентам дополнительные данные об ошибке.
Ну коль заговорили про фронтенд, давайте пофантазируем как им работать с такими ошибками. На верхнем уровне одну ошибку в модальном окне вывести не проблема, а если ошибок две? А если у нас сложное приложение и ошибки надо показывать в разных частях приложения? Вот тут у фронтендера начинается просто адская боль и печаль с таким массивом ошибок. Его надо отдельно парсить, понимать какая именно ошибка произошла (например через extensions.code
). Как-то передать ошибку в нужную компоненту и на нужный уровень. В общем, приходится сильно изгаляться в коде пробросом лишних проперти и логикой.
Если вам интересно как бэкендер может упростить жизнь фронтендеру, то обязательно читайте следующий раздел.
Что такое пользовательские ошибки? Ну это когда вам где-то в приложении надо вывести “запись не найдена”, или “у вас нет прав просматривать этот контент”, или “необходимо подтвердить возраст” или в списке на 23 элементе показать что “запись удалена”.
Если пользоваться стандартным механизмом ошибок GraphQL. То на фронтенде приходится сильно изгаляться, чтобы пробросить ошибку в нужное место.
Но эту проблему можно достаточно элегантно решить, если ошибки возвращать прямо в data
на нужном уровне, а не через глобальный массив errors
. Для этого в GraphQL есть Union-типы
, которые возвращают либо запись с данными, либо ошибку.
Давайте сразу к живому примеру. Представим что нам надо вернуть список видео. Причем какие-то видео в обработке, другие перед просмотром необходимо купить или подтвердить свой возраст. Так давайте и будем возвращать список, который может вернуть Union-тип из Video
, VideoInProgressProblem
, VideoNeedBuyProblem
и VideoApproveAgeProblem
. Со стороны фронтендера можно тогда написать вот такой запрос:
query {
list {
__typename # <----- магическое поле, которое вернет имя типа для каждой записи
...on Video {
title
url
}
...on VideoInProgressProblem {
estimatedTime
}
...on VideoNeedBuyProblem {
price
}
...on VideoApproveAgeProblem {
minAge
}
}
}
Т.е. используем фрагменты на конкретных типах и запрашиваем поле __typename
, которое возвращает имя типа. К запросу выше GraphQL-ответ будет следующий:
{
data: {
list: [
{ __typename: 'Video', title: 'DOM2 in the HELL', url: 'https://url' },
{ __typename: 'VideoApproveAgeProblem', minAge: 21 },
{ __typename: 'VideoNeedBuyProblem', price: 10 },
{ __typename: 'VideoInProgressProblem', estimatedTime: 220 },
],
},
}
При таком подходе фронтендер знает какие вообще ошибки могут быть. Также он получает ошибки в нужной компоненте, на нужном уровне. Код захламляется только там, где необходимо разобрать разные варианты пользовательских ошибок и вывести либо данные, либо красивый блок с ошибочкой.
Причем фронтендеры могут легко понять, какой тип ошибки вернулся. И при этом получить дополнительные данные по ошибке, если она их возвращает. Это же просто обычный тип в схеме, который может содержать в себе любые необходимые поля.
Для себя я вынес одно правило, что пользовательским ошибкам лучше всего давать суффикс Problem
, а не Error
. Это позволяет избежать путаницы как на бэкенде, так и на фронтенде.
Как это дело можно организовать на бэкенде? Достаточно просто. Вот пример:
// Объявляем класс Видео
class Video {
title: string;
url: string;
constructor({ title, url }) {
this.title = title;
this.url = url;
}
}
// И сразу же объявим GraphQL-тип
const VideoType = new GraphQLObjectType({
name: 'Video',
fields: () => ({
title: { type: GraphQLString },
url: { type: GraphQLString },
}),
});
// Объявим классы проблем (ошибок)
class VideoInProgressProblem {
constructor({ estimatedTime }) {
this.estimatedTime = estimatedTime;
}
}
class VideoNeedBuyProblem {
constructor({ price }) {
this.price = price;
}
}
class VideoApproveAgeProblem {
constructor({ minAge }) {
this.minAge = minAge;
}
}
// И их типы для GraphQL
const VideoInProgressProblemType = new GraphQLObjectType({
name: 'VideoInProgressProblem',
fields: () => ({
estimatedTime: { type: GraphQLInt },
}),
});
const VideoNeedBuyProblemType = new GraphQLObjectType({
name: 'VideoNeedBuyProblem',
fields: () => ({
price: { type: GraphQLInt },
}),
});
const VideoApproveAgeProblemType = new GraphQLObjectType({
name: 'VideoApproveAgeProblem',
fields: () => ({
minAge: { type: GraphQLInt },
}),
});
// Ну а теперь самое интересное.
// Объявляем наш UNION-тип который будет возвращать либо видео, либо проблему-ошибку
const VideoResultType = new GraphQLUnionType({
// Даем имя типу.
// Здорово если если вы выработаете конвенцию в своей команде
// и к таким Union-типам будете добавлять суффикс Result
name: 'VideoResult',
// как хорошие бекендеры добавляем какое-нибудь описание
description: 'Video or problems',
// объявляем типы через массив, которые могут быть возвращены
types: () => [
VideoType,
VideoInProgressProblemType,
VideoNeedBuyProblemType,
VideoApproveAgeProblemType,
],
// Ну и самое главное надо объявить функцию определения типа.
// resolve-функции (смотри ниже поле Query.list) просто возвращают JS-объект
// но вот GraphQL'ю нужно как-то JS-объект, сконвертировать в GraphQL-тип
// иначе как он узнает что надо записать в поле __typename
resolveType: value => {
if (value instanceof Video) {
return VideoType;
} else if (value instanceof VideoInProgressProblem) {
return VideoInProgressProblemType;
} else if (value instanceof VideoNeedBuyProblem) {
return VideoNeedBuyProblemType;
} else if (value instanceof VideoApproveAgeProblem) {
return VideoApproveAgeProblemType;
}
return null;
},
});
// Ну и вишенка на торте
// Пишем простую схемку, которая нам возвращает массив из Видео и Ошибок-Проблем.
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
list: {
type: new GraphQLList(VideoResultType),
resolve: () => {
return [
new Video({ title: 'DOM2 in the HELL', url: 'https://url' }),
new VideoApproveAgeProblem({ minAge: 21 }),
new VideoNeedBuyProblem({ price: 10 }),
new VideoInProgressProblem({ estimatedTime: 220 }),
];
},
},
},
}),
});
Очень просто и красиво. А самое главное удобно для фронтендеров:
Любите брата фронтендера своего 😉 Иначе они придут с вилами!