Глобальные обработчики ошибок

В приложениях с большим количеством запросов локальная обработка ошибок быстро превращается в источник дублирования. Повторяющиеся try/catch, одинаковые уведомления, логирование и проверки статусов начинают расползаться по компонентам, хукам и сервисам.

TanStack Query предоставляет механизм глобальных обработчиков ошибок через конфигурацию QueryClient. Такие обработчики позволяют централизованно реагировать на ошибки:

  • запросов (queries);
  • мутаций (mutations);
  • фоновых обновлений;
  • автоматических повторов;
  • сетевых сбоев;
  • ошибок авторизации;
  • ошибок API.

Глобальные обработчики особенно полезны для:

  • показа уведомлений;
  • централизованного логирования;
  • отправки ошибок в Sentry;
  • автоматического logout;
  • редиректа на страницу авторизации;
  • обработки кодов 401, 403, 500;
  • отслеживания нестабильной сети;
  • унификации поведения приложения.

Архитектура глобальной обработки ошибок

В TanStack Query существует несколько уровней обработки ошибок:

  1. Локальный уровень:

    • onError внутри useQuery;
    • onError внутри useMutation.
  2. Глобальный уровень:

    • QueryCache;
    • MutationCache.
  3. Error Boundaries:

    • интеграция с React Error Boundary;
    • useErrorBoundary.
  4. Низкоуровневые interceptors:

    • Axios interceptors;
    • Fetch wrappers.

Глобальная обработка в TanStack Query располагается между сетевым слоем и UI-компонентами.


QueryCache

Создание глобального обработчика ошибок запросов

import {
  QueryClient,
  QueryCache
} from '@tanstack/react-query'

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      console.error('Ошибка запроса:', error)

      console.log('Query key:', query.queryKey)
    }
  })
})

Обработчик получает:

(error, query)

Где:

  • error — объект ошибки;
  • query — экземпляр Query.

Доступ к queryKey

Одна из самых полезных возможностей — анализ ключа запроса.

onError: (error, query) => {
  console.log(query.queryKey)
}

Пример:

['users', 15]

или:

['posts', {
  page: 2
}]

Это позволяет:

  • различать типы запросов;
  • применять разные сценарии обработки;
  • исключать служебные запросы;
  • формировать диагностические сообщения.

Пример условной обработки

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      const [scope] = query.queryKey

      if (scope === 'auth') {
        console.error('Ошибка авторизации')
      }

      if (scope === 'payments') {
        console.error('Ошибка платежного API')
      }
    }
  })
})

MutationCache

Глобальная обработка ошибок мутаций

Мутации имеют собственный кэш.

import {
  QueryClient,
  MutationCache
} from '@tanstack/react-query'

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onError: (error, variables, context, mutation) => {
      console.error(error)
    }
  })
})

Сигнатура:

(error, variables, context, mutation)

Анализ mutationKey

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onError: (error, variables, context, mutation) => {
      console.log(mutation.options.mutationKey)
    }
  })
})

Пример:

['update-user']

или:

['delete-post']

Разделение ошибок по типам мутаций

onError: (error, variables, context, mutation) => {
  const key = mutation.options.mutationKey?.[0]

  switch (key) {
    case 'create-order':
      console.error('Ошибка создания заказа')
      break

    case 'upload-avatar':
      console.error('Ошибка загрузки файла')
      break
  }
}

Централизованные уведомления

Интеграция с toast-системами

Наиболее популярный сценарий — глобальный показ уведомлений.

import { toast } from 'react-toastify'

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      toast.error('Не удалось загрузить данные')
    }
  })
})

Исключение фоновых запросов

Иногда уведомления от background refetch раздражают пользователей.

Например:

  • окно неактивно;
  • сеть нестабильна;
  • запросы обновляются автоматически.

Проверка состояния запроса:

onError: (error, query) => {
  if (query.state.data !== undefined) {
    return
  }

  toast.error('Ошибка загрузки')
}

Логика:

  • если данные уже были — ошибка произошла при background refetch;
  • пользователь продолжает видеть старые данные;
  • уведомление можно не показывать.

Обработка HTTP-статусов

Проверка статуса ошибки

Чаще всего приложения используют Axios.

onError: (error) => {
  if (error.response?.status === 401) {
    console.log('Не авторизован')
  }
}

Автоматический logout

onError: (error) => {
  if (error.response?.status === 401) {
    localStorage.removeItem('token')

    window.location.href = '/login'
  }
}

Обработка 403

onError: (error) => {
  if (error.response?.status === 403) {
    console.error('Недостаточно прав')
  }
}

Обработка 500

onError: (error) => {
  if (error.response?.status >= 500) {
    console.error('Ошибка сервера')
  }
}

Интеграция с Axios Interceptors

Разделение ответственности

Важно понимать разницу:

Axios interceptors

Отвечают за:

  • преобразование запросов;
  • refresh token;
  • headers;
  • retry transport-уровня.

TanStack Query

Отвечает за:

  • UI-состояние;
  • реакцию интерфейса;
  • кэш;
  • глобальные уведомления;
  • поведение компонентов.

Комбинированная архитектура

import axios from 'axios'

export const api = axios.create({
  baseURL: '/api'
})

api.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      console.error('Refresh token flow')
    }

    return Promise.reject(error)
  }
)

TanStack Query:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      console.error('UI-level error')
    }
  })
})

Логирование ошибок

Отправка ошибок в Sentry

import * as Sentry from '@sentry/react'

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      Sentry.captureException(error, {
        extra: {
          queryKey: query.queryKey
        }
      })
    }
  })
})

Логирование мутаций

mutationCache: new MutationCache({
  onError: (
    error,
    variables,
    context,
    mutation
  ) => {
    Sentry.captureException(error, {
      extra: {
        variables,
        mutationKey: mutation.options.mutationKey
      }
    })
  }
})

Error Boundaries

useErrorBoundary

TanStack Query умеет пробрасывать ошибки в React Error Boundary.

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  useErrorBoundary: true
})

Теперь ошибка не останется внутри query-state, а попадёт в boundary.


Условная отправка ошибок в boundary

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  useErrorBoundary: (error) => {
    return error.response?.status >= 500
  }
})

Сценарий:

  • 400 и 404 обрабатываются локально;
  • 500 вызывает глобальную аварийную страницу.

Ошибки background refetch

Особенность фоновых ошибок

Одна из главных особенностей TanStack Query — разделение:

  • initial loading error;
  • background refetch error.

Проверка наличия данных

const {
  data,
  error,
  isError
} = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers
})

Если:

data !== undefined

значит:

  • запрос уже был успешен ранее;
  • ошибка возникла во время обновления.

Практическая схема

onError: (error, query) => {
  const hasCachedData =
    query.state.data !== undefined

  if (!hasCachedData) {
    toast.error('Не удалось загрузить данные')
    return
  }

  console.warn('Ошибка фонового обновления')
}

Retry и глобальные ошибки

Важная особенность retry

По умолчанию TanStack Query делает retry.

retry: 3

Это означает:

  • ошибка не считается финальной;
  • onError вызывается только после исчерпания retry.

Анализ failureCount

retry: (failureCount, error) => {
  console.log(failureCount)

  return failureCount < 3
}

Исключение retry для 401

retry: (failureCount, error) => {
  if (error.response?.status === 401) {
    return false
  }

  return failureCount < 3
}

Глобальная конфигурация defaultOptions

Общая обработка ошибок

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        console.error(error)
      }
    },

    mutations: {
      onError: (error) => {
        console.error(error)
      }
    }
  }
})

Отличие от QueryCache

defaultOptions

Работает как дефолт для каждого запроса.

Можно переопределить локально:

useQuery({
  onError: () => {}
})

QueryCache.onError

Всегда вызывается глобально.

Не переопределяется.


Совместное использование

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: globalErrorHandler
  }),

  defaultOptions: {
    queries: {
      onError: localDefaultHandler
    }
  }
})

Порядок вызовов:

  1. локальный onError;
  2. defaultOptions onError;
  3. QueryCache onError.

Предотвращение дублирования уведомлений

Типичная проблема

Без контроля можно получить:

  • toast из Axios interceptor;
  • toast из QueryCache;
  • toast из useQuery.

В результате пользователь видит несколько одинаковых уведомлений.


Централизация

Наиболее стабильная архитектура:

  • interceptors → только transport-логика;
  • TanStack Query → UI-ошибки;
  • компоненты → редкие специфические случаи.

Маркировка ошибок

Иногда ошибку помечают вручную:

const error = new Error('API Error')

error.isHandled = true

Проверка:

onError: (error) => {
  if (error.isHandled) {
    return
  }

  toast.error(error.message)
}

Пользовательские классы ошибок

Создание типизированных ошибок

class ApiError extends Error {
  constructor(
    message,
    status
  ) {
    super(message)

    this.status = status
  }
}

Использование

async function fetchUsers() {
  const response = await fetch('/users')

  if (!response.ok) {
    throw new ApiError(
      'Ошибка API',
      response.status
    )
  }

  return response.json()
}

Глобальная обработка

onError: (error) => {
  if (error instanceof ApiError) {
    console.log(error.status)
  }
}

Игнорирование некоторых ошибок

Подавление уведомлений

Не все ошибки должны отображаться.

Например:

  • 404;
  • отменённые запросы;
  • offline errors;
  • background sync errors.

Исключение AbortError

onError: (error) => {
  if (error.name === 'AbortError') {
    return
  }

  toast.error(error.message)
}

Игнорирование 404

onError: (error) => {
  if (error.response?.status === 404) {
    return
  }

  toast.error('Ошибка')
}

Глобальные обработчики и SSR

Особенности SSR

На сервере:

  • нет window;
  • нет localStorage;
  • нет toast;
  • нет browser APIs.

Защита browser-only логики

onError: (error) => {
  if (typeof window === 'undefined') {
    return
  }

  toast.error('Ошибка')
}

Продвинутая архитектура error handling

Выделение error service

Крупные приложения часто выносят обработку ошибок в отдельный сервис.

export function handleApiError(error) {
  if (error.response?.status === 401) {
    logout()
    return
  }

  if (error.response?.status >= 500) {
    showServerError()
    return
  }

  showGenericError()
}

Интеграция с QueryClient

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: handleApiError
  }),

  mutationCache: new MutationCache({
    onError: handleApiError
  })
})

Практический production-пример

import {
  QueryClient,
  QueryCache,
  MutationCache
} from '@tanstack/react-query'

import { toast } from 'react-toastify'

function globalErrorHandler(error, query) {
  if (error.name === 'AbortError') {
    return
  }

  const status = error.response?.status

  if (status === 401) {
    localStorage.removeItem('token')

    window.location.href = '/login'

    return
  }

  if (status === 403) {
    toast.error('Недостаточно прав')
    return
  }

  if (status >= 500) {
    toast.error('Ошибка сервера')
    return
  }

  const hasCachedData =
    query?.state?.data !== undefined

  if (!hasCachedData) {
    toast.error(
      error.message ||
      'Ошибка запроса'
    )
  }
}

export const queryClient =
  new QueryClient({
    queryCache: new QueryCache({
      onError: globalErrorHandler
    }),

    mutationCache: new MutationCache({
      onError: globalErrorHandler
    })
  })

Типичные ошибки при глобальной обработке

Использование toast внутри queryFn

Плохой подход:

async function fetchUsers() {
  try {
    const response = await api.get('/users')

    return response.data
  } catch (error) {
    toast.error('Ошибка')

    throw error
  }
}

Проблемы:

  • дублирование;
  • смешение UI и data-layer;
  • сложность тестирования;
  • невозможность централизованной обработки.

Обработка всех ошибок одинаково

Неправильно:

toast.error('Ошибка')

Для любых случаев:

  • 401;
  • 403;
  • 404;
  • 500;
  • network error.

Грамотная архитектура требует категоризации ошибок.


Игнорирование background refetch

Без проверки кэшированных данных приложение может постоянно показывать уведомления при нестабильной сети.


Повторная обработка уже обработанных ошибок

Если Axios interceptor уже выполнил logout, TanStack Query не должен делать это повторно.


Рекомендации для production

Оптимальная схема

Network layer

  • Axios;
  • Fetch wrapper;
  • interceptors;
  • refresh tokens.

Query layer

  • QueryClient;
  • QueryCache;
  • MutationCache;
  • retry;
  • cache logic.

UI layer

  • notifications;
  • modals;
  • fallback screens;
  • Error Boundaries.

Рекомендуемый набор обработок

QueryCache

  • уведомления;
  • background error logic;
  • логирование.

MutationCache

  • ошибки сохранения;
  • rollback;
  • уведомления форм.

Axios

  • refresh token;
  • headers;
  • transport retry.

Error Boundary

  • критические ошибки UI;
  • серверные аварии;
  • runtime exceptions.