Обработка глобальных ошибок

В приложениях с большим количеством запросов локальная обработка ошибок внутри каждого useQuery и useMutation быстро приводит к дублированию кода, несогласованным уведомлениям и сложностям поддержки. TanStack Query предоставляет механизмы централизованной обработки ошибок на уровне:

  • QueryClient
  • QueryCache
  • MutationCache
  • глобальных default options
  • HTTP-клиента
  • boundary-компонентов React

Глобальная обработка ошибок позволяет:

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

Источники ошибок в TanStack Query

Ошибки могут появляться в нескольких местах:

Ошибки query-функций

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

Если fetchUsers выбрасывает исключение или возвращает rejected Promise, TanStack Query переводит запрос в состояние ошибки.


Ошибки мутаций

useMutation({
    mutationFn: createUser
})

Ошибки mutation-функций работают аналогично query.


Ошибки сетевого уровня

  • отсутствие соединения;
  • timeout;
  • DNS-проблемы;
  • CORS;
  • обрыв соединения.

Ошибки бизнес-логики

Например:

{
    "error": "EMAIL_ALREADY_EXISTS"
}

Сервер может вернуть HTTP 200, но содержать логическую ошибку.


Ошибки авторизации

Наиболее распространённые:

  • 401 Unauthorized
  • 403 Forbidden
  • истечение JWT
  • недействительная refresh-сессия

Базовая локальная обработка ошибок

Стандартный вариант:

const query = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    onError(error) {
        console.error(error)
    }
})

Недостатки:

  • повторение одинакового кода;
  • отсутствие единой стратегии;
  • сложность изменения поведения;
  • множественные уведомления;
  • разрозненное логирование.

Глобальная обработка через QueryCache

Основной механизм глобальной обработки query-ошибок — QueryCache.

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

const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError(error, query) {
            console.error('Global query error:', error)
        }
    })
})

Теперь любая ошибка query будет проходить через единый обработчик.


Аргументы глобального обработчика query

error

Объект ошибки:

onError(error)

Может содержать:

  • HTTP-статус;
  • stack trace;
  • response body;
  • кастомные поля.

query

Экземпляр query:

onError(error, query)

Позволяет получить:

query.queryKey
query.state
query.meta

Использование queryKey в обработке ошибок

Часто требуется разное поведение для разных запросов.

const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError(error, query) {
            if (query.queryKey[0] === 'profile') {
                console.error('Ошибка профиля')
            }

            if (query.queryKey[0] === 'admin') {
                console.error('Ошибка административного API')
            }
        }
    })
})

Глобальная обработка mutation-ошибок

Для мутаций используется MutationCache.

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

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

Аргументы MutationCache.onError

error

Ошибка mutation.


variables

Аргументы мутации:

mutationFn(variables)

Пример:

{
    email: 'test@test.com'
}

context

Контекст optimistic updates.


mutation

Экземпляр mutation:

mutation.options
mutation.state
mutation.meta

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

Одна из главных задач глобальной обработки — единая система уведомлений.

Пример с toast-системой:

queryCache: new QueryCache({
    onError(error) {
        toast.error('Произошла ошибка')
    }
})

Исключение дублирования уведомлений

Без защиты несколько компонентов могут показать одинаковые ошибки одновременно.

Проблемный сценарий:

useQuery(...)
useQuery(...)
useQuery(...)

Если сервер недоступен:

  • три toast-уведомления;
  • три логирования;
  • три одинаковых сообщения.

Дедупликация ошибок

Распространённый подход:

const shownErrors = new Set()

function notifyOnce(message: string) {
    if (shownErrors.has(message)) {
        return
    }

    shownErrors.add(message)

    toast.error(message)

    setTimeout(() => {
        shownErrors.delete(message)
    }, 3000)
}

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

queryCache: new QueryCache({
    onError(error) {
        notifyOnce('Сервер недоступен')
    }
})

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

Обычно обработка строится вокруг кодов ответа.

Пример:

onError(error) {
    const status = error.response?.status

    switch (status) {
        case 401:
            break

        case 403:
            break

        case 404:
            break

        case 500:
            break
    }
}

Централизованная обработка 401

Наиболее важный сценарий — истечение авторизации.

queryCache: new QueryCache({
    onError(error) {
        if (error.response?.status === 401) {
            logout()
        }
    }
})

Redirect при потере авторизации

Часто требуется очистка приложения:

function logout() {
    localStorage.removeItem('token')

    window.location.href = '/login'
}

Очистка query-кеша при logout

После выхода необходимо очищать cache.

function logout() {
    queryClient.clear()

    localStorage.removeItem('token')

    window.location.href = '/login'
}

Обработка refresh token

Более сложная архитектура:

  1. запрос получает 401;
  2. запускается refresh-token;
  3. access token обновляется;
  4. запрос повторяется.

TanStack Query обычно не занимается этим напрямую — логика располагается в HTTP-клиенте.


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

Наиболее распространённый вариант — interceptor.

import axios from 'axios'

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

api.interceptors.response.use(
    response => response,
    async error => {
        if (error.response?.status === 401) {
            await refreshToken()
        }

        return Promise.reject(error)
    }
)

Связь Axios и TanStack Query

Query-функция становится максимально простой:

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

    return response.data
}

Все:

  • retry;
  • refresh token;
  • логирование;
  • преобразование ошибок

могут выполняться централизованно.


Нормализация ошибок

Разные API возвращают ошибки в разных форматах.

Пример проблем:

{
    "message": "Invalid token"
}
{
    "error": "INVALID_TOKEN"
}
{
    "errors": [
        {
            "message": "Validation failed"
        }
    ]
}

Единый формат ошибок

Полезно создавать универсальную структуру.

type AppError = {
    status: number
    code: string
    message: string
}

Преобразование ошибок

function normalizeError(error): AppError {
    return {
        status: error.response?.status ?? 500,
        code: error.response?.data?.code ?? 'UNKNOWN',
        message:
            error.response?.data?.message ??
            'Unexpected error'
    }
}

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

onError(error) {
    const appError = normalizeError(error)

    toast.error(appError.message)
}

Использование meta для управления обработкой

TanStack Query поддерживает поле meta.

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    meta: {
        silent: true
    }
})

Доступ к meta в глобальном обработчике

queryCache: new QueryCache({
    onError(error, query) {
        if (query.meta?.silent) {
            return
        }

        toast.error('Ошибка запроса')
    }
})

Silent queries

Полезный паттерн для:

  • фоновых обновлений;
  • prefetch;
  • polling;
  • скрытых запросов.

Разделение пользовательских и системных ошибок

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

Пример:

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

Но:

404

может быть частью обычной логики приложения.


Ошибки в background refetch

Частая проблема — уведомления при скрытом refetch.

Например:

refetchInterval: 5000

Если сервер недоступен:

  • пользователь получает toast каждые 5 секунд.

Проверка состояния query

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

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

Если данные уже были загружены ранее, ошибка refetch может не отображаться.


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

По умолчанию TanStack Query повторяет запросы.

retry: 3

Важно понимать:

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

Кастомная стратегия retry

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

    return failureCount < 3
}

Исключение retry для авторизации

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

    return true
}

Глобальные defaultOptions

Централизованные настройки:

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: 2
        },
        mutations: {
            retry: false
        }
    }
})

useErrorBoundary

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

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

Поведение useErrorBoundary

Вместо локального состояния ошибки:

isError
error

ошибка будет выброшена вверх по дереву React.


Error Boundary

Пример:

<ErrorBoundary fallback={<ErrorScreen />}>
    <UsersPage />
</ErrorBoundary>

Когда использовать Error Boundary

Подходит для:

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

Не подходит для:

  • validation errors;
  • 404;
  • бизнес-ошибок формы.

Условный useErrorBoundary

Можно использовать функцию:

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

Разделение ошибок по критичности

Пример архитектуры:

Тип ошибки Поведение
400 показать validation
401 logout
403 redirect
404 показать пустое состояние
500 Error Boundary

Mutation-ошибки и формы

Часто ошибки мутаций относятся к validation.

Пример:

{
    "errors": {
        "email": "Already exists"
    }
}

Такие ошибки обычно не должны идти в глобальный toast.


Отключение глобальных уведомлений

Через meta:

useMutation({
    mutationFn: createUser,
    meta: {
        skipGlobalError: true
    }
})

Проверка meta в MutationCache

mutationCache: new MutationCache({
    onError(error, variables, context, mutation) {
        if (mutation.meta?.skipGlobalError) {
            return
        }

        toast.error('Ошибка операции')
    }
})

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

Глобальная обработка — идеальное место для интеграции:

  • Sentry;
  • LogRocket;
  • Datadog;
  • New Relic.

Пример интеграции с Sentry

queryCache: new QueryCache({
    onError(error, query) {
        Sentry.captureException(error, {
            extra: {
                queryKey: query.queryKey
            }
        })
    }
})

Фильтрация логирования

Не все ошибки нужно отправлять в мониторинг.

Пример:

if (status >= 500) {
    Sentry.captureException(error)
}

Обработка offline-состояния

Иногда ошибка означает отсутствие интернета.

if (!navigator.onLine) {
    toast.error('Нет соединения с интернетом')
}

networkMode

TanStack Query поддерживает сетевые режимы.

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    networkMode: 'offlineFirst'
})

Ошибки prefetch

Prefetch-запросы тоже могут вызывать глобальные ошибки.

queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
})

Часто для них используется:

meta: {
    silent: true
}

Централизованный error service

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

class ErrorService {
    handle(error) {
        const normalized = normalizeError(error)

        this.log(normalized)

        this.notify(normalized)
    }

    log(error) {}

    notify(error) {}
}

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

const errorService = new ErrorService()

queryCache: new QueryCache({
    onError(error) {
        errorService.handle(error)
    }
})

Типизация ошибок

В TypeScript полезно создавать собственные классы.

class ApiError extends Error {
    status: number
    code: string

    constructor(message, status, code) {
        super(message)

        this.status = status
        this.code = code
    }
}

Проверка instanceof

if (error instanceof ApiError) {
    console.log(error.status)
}

Архитектура production-обработки ошибок

Типичная схема:

  1. HTTP-клиент:

    • interceptors;
    • refresh token;
    • normalization.
  2. TanStack Query:

    • retry;
    • global handlers;
    • meta flags.
  3. UI:

    • toast;
    • modal;
    • error pages;
    • boundaries.
  4. Monitoring:

    • Sentry;
    • logging;
    • analytics.

Частые ошибки архитектуры

Дублирование toast

Одинаковые уведомления из:

  • query;
  • interceptor;
  • component;
  • global handler.

Обработка в каждом компоненте

onError() {}

в десятках компонентов приводит к хаосу.


Смешивание validation и системных ошибок

Validation — часть UI-логики формы, а не глобальная авария.


Retry для 401

Приводит к бесконечным циклам refresh-token.


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

Постоянные уведомления ухудшают UX.


Отсутствие нормализации

Разные форматы ошибок усложняют поддержку и типизацию.


Практическая production-конфигурация

const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError(error, query) {
            if (query.meta?.silent) {
                return
            }

            const normalized = normalizeError(error)

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

            if (normalized.status === 401) {
                logout()
            }
        }
    }),

    mutationCache: new MutationCache({
        onError(error, variables, context, mutation) {
            if (mutation.meta?.skipGlobalError) {
                return
            }

            const normalized = normalizeError(error)

            toast.error(normalized.message)
        }
    }),

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

                return failureCount < 3
            }
        }
    }
})