Обработка ошибок в GraphQL: от шаблонных исключений к продуманной коммуникации

GraphQL предоставляет гибкость в запросах данных, но эта свобода усложняет обработку ошибок по сравнению с REST. Типичный сценарий: клиент делает запрос с несколькими вложенными полями, сервер частично падает, и фронтенд получает ответ с HTTP-статусом 200, но с неочевидными ошибками в теле. Рассмотрим, как превратить такие ситуации из источника хаоса в управляемый процесс.

Почему errors array недостаточно

Стандартный ответ GraphQL при ошибке выглядит так:

json
{
  "data": null,
  "errors": [
    {"message": "Permission denied", "path": ["createPost"]}
  ]
}

Для простых случаев это работает, но когда:

  1. Ошибки возникают в разных частях составного запроса
  2. Клиенту нужны метаданны для локализации сообщений
  3. Требуется дополнительная логика восстановления

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

Расширяем Error Handling

В Apollo Server кастомизация начинается с создания собственного класса ошибок:

typescript
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, клиент может определить критичность:

graphql
extend type Mutation {
  createPost(input: PostInput!): PostPayload! 
    @httpResponse(status: 201)
}
  • Метаданные в extensions
    Параметры для аналитики или локализации:
json
{
  "errors": [{
    "message": "Maximum posts per day exceeded",
    "extensions": {
      "code": "POST_LIMIT",
      "limit": 5,
      "i18nKey": "errors.postLimit"
    }
  }]
}

Согласованные payload-ы для мутаций

Рефакторинг стандартного подхода: вместо возврата напрямую объекта Post, оборачиваем его в Payload с union-типами:

graphql
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!
}

На клиенте это позволяет использовать статическую типизацию:

typescript
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 для сбора метрик:

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

Клиентские стратегии восстановления

Помимо отображения сообщений, клиент должен уметь:

  1. Ретрить безопасные операции
    Для сетевых сбоев или временных ошибок 500:
typescript
const { data } = useQuery(GET_POSTS, {
  fetchPolicy: 'network-only',
  onError: (err) => {
    if (isRetriable(err)) {
      retry();
    }
  }
});
  1. Инвалидировать кэш при конфликтах
    После ошибки FORBIDDEN очистить кэш авторизации:
typescript
const errorLink = onError(({ graphQLErrors, operation }) => {
  graphQLErrors?.forEach(({ extensions }) => {
    if (extensions?.code === 'AUTH_EXPIRED') {
      client.cache.evict({ fieldName: 'currentUser' });
    }
  });
});
  1. Локализовать сообщения
    Использовать i18nKey из extensions:
jsx
const ErrorMessage = ({ error }) => (
  <div>{t(error.extensions?.i18nKey || 'unknownError')}</div>
);

Баланс между гибкостью и строгостью

GraphQL-схема должна явно декларировать возможные ошибки для каждой операции. Это требует дисциплины, но окупается:

  • Клиенты могут обрабатывать ошибки детально, без хакаемых условий
  • Автогенерируемые TypeScript-типы для ошибок
  • Документация API становится точной (в том же GraphQL Schema)

Однако не стоит пытаться прописать все возможные ошибки заранее — выделяйте «доменные» ошибки, влияющие на клиентскую логику. Технические сбои (база данных упала) остаются в общих кодах.

Когда стандарты мешают

Не все ошибки должны следовать правилам. Для быстрого прототипирования или внутренних API можно возвращать упрощенные ошибки. Но как только клиентская кодовая база растет, инвестиции в структурированные ошибки сократят количество:

  • Некликабельных кнопок из-за неправильной обработки состояний
  • Багов в логике восстановления после сбоев
  • Времени на дебаг неочевидных сценариев

Ошибки — это не просто «что-то сломалось». В GraphQL они становятся полноценной частью коммуникационной модели между сервисами. Обрабатывая их как второсортных граждан, мы создаем хрупкие системы. Структурированный подход превращает ошибки в инструмент диалога между клиентом и сервером.