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

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

Глобальная обработка ошибок решает несколько задач одновременно:

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

Без глобальной обработки ошибок код часто выглядит так:

const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    onError: (error) => {
        toast.error('Ошибка загрузки пользователей');
    }
});

Проблема в том, что аналогичный onError начинает копироваться десятки или сотни раз.


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

Ошибки запросов

Наиболее распространённый тип — ошибки внутри queryFn.

const fetchUsers = async () => {
    const response = await fetch('/api/users');

    if (!response.ok) {
        throw new Error('Ошибка загрузки');
    }

    return response.json();
};

Если функция выбрасывает исключение, TanStack Query переводит запрос в состояние error.


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

Ошибки могут происходить внутри mutationFn.

const createUser = async (payload) => {
    const response = await fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(payload)
    });

    if (!response.ok) {
        throw new Error('Ошибка создания');
    }

    return response.json();
};

Runtime-ошибки

Иногда ошибка возникает уже после получения данных.

select: (data) => data.items.map(...)

Если items отсутствует, приложение может получить исключение уже на этапе обработки результата.


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

Сервер может вернуть:

  • 401 Unauthorized
  • 403 Forbidden

Такие ошибки обычно требуют глобальной реакции:

  • удаления токена;
  • перенаправления на страницу входа;
  • очистки кэша;
  • обновления access token.

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

Обычно система состоит из нескольких уровней:

Уровень Назначение
Query Cache Глобальные ошибки запросов
Mutation Cache Глобальные ошибки мутаций
API Layer Нормализация ошибок
UI Layer Toast, модальные окна
Monitoring Layer Sentry, LogRocket
Auth Layer Logout и refresh token

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

TanStack Query позволяет перехватывать ошибки на уровне QueryCache.

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

const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError: (error, query) => {
            console.error(error);

            console.log(query.queryKey);
        }
    })
});

Теперь любой запрос, завершившийся ошибкой, попадёт в этот обработчик.


Параметры onError

error

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

onError: (error) => {
    console.log(error.message);
}

query

Полный объект запроса.

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

Это особенно полезно для:

  • аналитики;
  • фильтрации ошибок;
  • специальных правил обработки.

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

Для мутаций используется отдельный обработчик.

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

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

Разделение query и mutation ошибок

На практике ошибки запросов и мутаций обрабатываются по-разному.

Query

Обычно:

  • fallback UI;
  • повторные запросы;
  • уведомления;
  • retry.

Mutation

Чаще:

  • показ ошибок формы;
  • rollback optimistic update;
  • отмена локальных изменений.

Централизованный toast handler

Очень распространённый сценарий.

const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError: (error) => {
            toast.error(error.message);
        }
    })
});

Однако без фильтрации интерфейс может оказаться завален уведомлениями.


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

Один запрос может:

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

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


Фильтрация toast-уведомлений

Проверка количества попыток

onError: (error, query) => {
    if (query.state.fetchFailureCount === 1) {
        toast.error(error.message);
    }
}

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

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

    toast.error(error.message);
}

Если данные уже были получены ранее, ошибка background refetch может не требовать уведомления.


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

Серверы редко возвращают ошибки в одинаковом формате.

Например:

{
  "message": "Validation failed"
}

или:

{
  "error": {
    "text": "Access denied"
  }
}

или:

{
  "errors": [
    "Invalid email"
  ]
}

Без нормализации UI-код начинает усложняться.


Создание единого Error объекта

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

        this.status = status;
        this.data = data;
    }
}

Централизованный fetch wrapper

export const apiClient = async (
    url,
    options = {}
) => {
    const response = await fetch(url, options);

    const data = await response.json();

    if (!response.ok) {
        throw new ApiError(
            data.message || 'API Error',
            response.status,
            data
        );
    }

    return data;
};

Теперь все ошибки имеют единый формат.


Проверка HTTP-статусов

401

if (error.status === 401) {
    logout();
}

403

if (error.status === 403) {
    navigate('/forbidden');
}

500

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

Глобальный logout при 401

Один из наиболее важных сценариев.

const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError: (error) => {
            if (error.status === 401) {
                authStore.logout();
            }
        }
    })
});

Очистка кэша при logout

После разлогинивания необходимо удалить приватные данные.

queryClient.clear();

Либо:

queryClient.removeQueries();

Retry и обработка ошибок

TanStack Query умеет автоматически повторять запросы.

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

Нежелательные retry

Некоторые ошибки повторять нельзя.

Например:

  • 401
  • 403
  • 404

Условный retry

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

    return failureCount < 3;
}

Глобальный retry policy

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: (count, error) => {
                if (error.status >= 400 &&
                    error.status < 500) {
                    return false;
                }

                return count < 3;
            }
        }
    }
});

Retry delay

retryDelay: (attempt) =>
    Math.min(1000 * 2 ** attempt, 30000)

Такой подход реализует exponential backoff.


Error Boundary

TanStack Query интегрируется с React Error Boundary.

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

Если запрос завершится ошибкой, исключение попадёт в React boundary.


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

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

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

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

  • ошибок валидации;
  • локальных form errors;
  • обычных toast-уведомлений.

Глобальный Error Boundary

<ErrorBoundary fallback={<PageError />}>
    <App />
</ErrorBoundary>

Совмещение toast и Error Boundary

Частая ошибка — показывать и boundary, и toast одновременно.

Это создаёт дублирование UI.

Обычно используют правило:

Тип ошибки Поведение
Критическая Error Boundary
Некритическая Toast
Form validation Inline errors

Локальная обработка поверх глобальной

Глобальный обработчик не запрещает локальный.

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    onError: (error) => {
        console.log('Локальная ошибка');
    }
});

Оба обработчика будут вызваны.


Приоритеты обработки

Обычно архитектура выглядит так:

  1. API layer
  2. Global cache handlers
  3. Local handlers
  4. UI components

Обработка background refetch ошибок

Особенно важная тема.

Сценарий:

  1. Пользователь открыл страницу.
  2. Данные успешно загрузились.
  3. Через некоторое время refetch завершился ошибкой.

Если полностью скрыть UI, это ухудшит UX.


Сохранение старых данных

TanStack Query сохраняет предыдущие данные даже после ошибки refetch.

if (query.isError && query.data) {
    return (
        <>
            <Warning />
            <Table data={query.data} />
        </>
    );
}

Разделение initial loading и refetch error

if (query.isPending) {
    return <Loader />;
}

if (query.isError && !query.data) {
    return <ErrorPage />;
}

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

Практически все production-приложения отправляют ошибки во внешние сервисы.

Например:

  • Sentry
  • LogRocket
  • Datadog

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

onError: (error, query) => {
    Sentry.captureException(error, {
        tags: {
            queryKey: JSON.stringify(
                query.queryKey
            )
        }
    });
}

Исключение неважных ошибок

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

Например:

  • отменённые запросы;
  • offline-состояние;
  • abort errors.

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

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

    captureException(error);
}

Offline ошибки

if (!navigator.onLine) {
    return;
}

Mutation rollback при ошибках

Часто используется вместе с optimistic updates.

useMutation({
    mutationFn: updateUser,

    onMutate: async (newUser) => {
        const previous =
            queryClient.getQueryData(['user']);

        queryClient.setQueryData(
            ['user'],
            newUser
        );

        return { previous };
    },

    onError: (
        error,
        variables,
        context
    ) => {
        queryClient.setQueryData(
            ['user'],
            context.previous
        );
    }
});

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

Иногда важно понимать:

  • какие запросы падают чаще;
  • какие endpoints нестабильны;
  • где проблемы сети;
  • сколько retry происходит.

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

const logError = ({
    error,
    queryKey
}) => {
    console.log({
        message: error.message,
        queryKey,
        date: Date.now()
    });
};

Универсальный error handler

const handleApiError = (
    error,
    options = {}
) => {
    if (error.status === 401) {
        authStore.logout();
        return;
    }

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

    if (options.log !== false) {
        captureException(error);
    }
};

Подключение универсального handler

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

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

Обработка ошибок формы

Ошибки валидации редко должны быть глобальными.

Например:

{
  "errors": {
    "email": "Invalid email"
  }
}

Подобные ошибки обрабатываются локально внутри формы.


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

Полезная практика — создавать категории:

class ValidationError extends Error {}
class AuthError extends Error {}
class NetworkError extends Error {}
class ServerError extends Error {}

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

if (error instanceof ValidationError) {
    return;
}

Кастомные error codes

{
  "code": "EMAIL_ALREADY_EXISTS"
}

Централизованный mapping кодов

const errorMessages = {
    EMAIL_ALREADY_EXISTS:
        'Email уже используется',

    INVALID_PASSWORD:
        'Неверный пароль'
};

Обработка ошибок в SSR

При использовании SSR ошибки могут происходить:

  • на сервере;
  • во время hydration;
  • после hydration.

Это требует отдельной стратегии.


Ошибки prefetchQuery

try {
    await queryClient.prefetchQuery({
        queryKey: ['users'],
        queryFn: fetchUsers
    });
} catch (error) {
    console.error(error);
}

Ошибки invalidateQueries

invalidateQueries сам по себе редко выбрасывает ошибки, но refetch после invalidation — может.


Devtools и анализ ошибок

TanStack Query Devtools позволяют:

  • видеть failed queries;
  • анализировать retry;
  • отслеживать cache state;
  • смотреть stack trace ошибок.

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

Показ toast на каждую ошибку

Создаёт перегрузку интерфейса.


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

Можно скрыть реальные проблемы сети.


Смешивание validation и system errors

Ошибки формы не должны попадать в глобальный handler.


Retry для 401

Бессмысленно и создаёт лишнюю нагрузку.


Полная блокировка UI при refetch error

Пользователь теряет уже загруженные данные.


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

Типичная production-архитектура выглядит так:

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

    mutationCache: new MutationCache({
        onError: globalMutationErrorHandler
    }),

    defaultOptions: {
        queries: {
            retry,
            retryDelay
        }
    }
});

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

API layer

  • нормализация ошибок;
  • HTTP statuses;
  • преобразование response.

Query layer

  • retry;
  • cache;
  • refetch.

Global handlers

  • toast;
  • logout;
  • logging.

UI layer

  • fallback pages;
  • inline validation;
  • banners.

Основная цель глобальной обработки ошибок

Главная задача состоит не в том, чтобы показать сообщение об ошибке, а в том, чтобы сделать поведение системы:

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