GraphQL предоставляет гибкость в запросах данных, но эта свобода усложняет обработку ошибок по сравнению с REST. Типичный сценарий: клиент делает запрос с несколькими вложенными полями, сервер частично падает, и фронтенд получает ответ с HTTP-статусом 200, но с неочевидными ошибками в теле. Рассмотрим, как превратить такие ситуации из источника хаоса в управляемый процесс.
Почему errors
array недостаточно
Стандартный ответ GraphQL при ошибке выглядит так:
{
"data": null,
"errors": [
{"message": "Permission denied", "path": ["createPost"]}
]
}
Для простых случаев это работает, но когда:
- Ошибки возникают в разных частях составного запроса
- Клиенту нужны метаданны для локализации сообщений
- Требуется дополнительная логика восстановления
поле message
быстро становится недостаточным. Клиентские разработчики начинают парсить строки, что хрупко и ненадежно.
Расширяем Error Handling
В Apollo Server кастомизация начинается с создания собственного класса ошибок:
import { ApolloError, ValidationError } from 'apollo-server';
class BusinessLogicError extends ApolloError {
constructor(
message: string,
code: string,
extensions?: Record<string, any>
) {
super(message, code, extensions);
}
}
throw new BusinessLogicError(
'Post limit reached',
'POST_LIMIT_EXCEEDED',
{ userId: context.currentUser.id }
);
Ключевые улучшения:
-
Коды ошибок (кроме HTTP-статусов)
AUTH_REQUIRED
,INVALID_INPUT
вместо обобщенных 400/500. -
Статусы для транспорта
Даже при HTTP 200, клиент может определить критичность:
extend type Mutation {
createPost(input: PostInput!): PostPayload!
@httpResponse(status: 201)
}
- Метаданные в extensions
Параметры для аналитики или локализации:
{
"errors": [{
"message": "Maximum posts per day exceeded",
"extensions": {
"code": "POST_LIMIT",
"limit": 5,
"i18nKey": "errors.postLimit"
}
}]
}
Согласованные payload-ы для мутаций
Рефакторинг стандартного подхода: вместо возврата напрямую объекта Post, оборачиваем его в Payload с union-типами:
type Post {
id: ID!
title: String!
}
interface Error {
message: String!
code: String!
}
type PostLimitError implements Error {
message: String!
code: String!
limit: Int!
}
union PostResult = Post | PostLimitError | AuthenticationError
type Mutation {
createPost(input: PostInput!): PostResult!
}
На клиенте это позволяет использовать статическую типизацию:
const [createPost] = useMutation(CREATE_POST_MUTATION);
const onSubmit = async () => {
const { data } = await createPost();
if (data.createPost.__typename === 'PostLimitError') {
showLimitModal(data.createPost.limit);
return;
}
navigateToPost(data.createPost.id);
};
Логирование на сервере: не просто console.log
Недостаточно просто пробросить ошибку клиенту. Серверная логика должна:
- Фиксировать контекст (пользователь, запрос, переменные)
- Разделять ошибки по severity (critical, warning)
- Интегрироваться с мониторингом (Sentry, DataDog)
Пример middleware для сбора метрик:
const errorLoggingPlugin = {
requestDidStart() {
return {
didEncounterErrors(context) {
const { errors, operation, context: { user } } = context;
errors.forEach(error => {
logManager.log({
type: 'GraphQLError',
code: error.extensions?.code,
operationName: operation?.name?.value,
user: user?.id,
path: error.path?.join('.'),
variables: context.request.variables
});
});
}
};
}
};
Клиентские стратегии восстановления
Помимо отображения сообщений, клиент должен уметь:
- Ретрить безопасные операции
Для сетевых сбоев или временных ошибок 500:
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'network-only',
onError: (err) => {
if (isRetriable(err)) {
retry();
}
}
});
- Инвалидировать кэш при конфликтах
После ошибкиFORBIDDEN
очистить кэш авторизации:
const errorLink = onError(({ graphQLErrors, operation }) => {
graphQLErrors?.forEach(({ extensions }) => {
if (extensions?.code === 'AUTH_EXPIRED') {
client.cache.evict({ fieldName: 'currentUser' });
}
});
});
- Локализовать сообщения
Использоватьi18nKey
изextensions
:
const ErrorMessage = ({ error }) => (
<div>{t(error.extensions?.i18nKey || 'unknownError')}</div>
);
Баланс между гибкостью и строгостью
GraphQL-схема должна явно декларировать возможные ошибки для каждой операции. Это требует дисциплины, но окупается:
- Клиенты могут обрабатывать ошибки детально, без хакаемых условий
- Автогенерируемые TypeScript-типы для ошибок
- Документация API становится точной (в том же GraphQL Schema)
Однако не стоит пытаться прописать все возможные ошибки заранее — выделяйте «доменные» ошибки, влияющие на клиентскую логику. Технические сбои (база данных упала) остаются в общих кодах.
Когда стандарты мешают
Не все ошибки должны следовать правилам. Для быстрого прототипирования или внутренних API можно возвращать упрощенные ошибки. Но как только клиентская кодовая база растет, инвестиции в структурированные ошибки сократят количество:
- Некликабельных кнопок из-за неправильной обработки состояний
- Багов в логике восстановления после сбоев
- Времени на дебаг неочевидных сценариев
Ошибки — это не просто «что-то сломалось». В GraphQL они становятся полноценной частью коммуникационной модели между сервисами. Обрабатывая их как второсортных граждан, мы создаем хрупкие системы. Структурированный подход превращает ошибки в инструмент диалога между клиентом и сервером.