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

RTK Query строится вокруг идеи предсказуемого жизненного цикла запроса. Ошибка является частью этого жизненного цикла и рассматривается как полноценное состояние запроса, наряду с loading, success и uninitialized.

Каждый endpoint в RTK Query может находиться в нескольких состояниях:

  • запрос выполняется;
  • запрос успешно завершён;
  • запрос завершился ошибкой;
  • запрос ещё не был отправлен.

При возникновении ошибки RTK Query автоматически:

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

Структура ошибки в RTK Query

Тип ошибки зависит от используемого baseQuery.

При использовании fetchBaseQuery ошибки обычно имеют одну из двух форм:

{
  status: 404,
  data: {
    message: 'Not found'
  }
}

Либо:

{
  status: 'FETCH_ERROR',
  error: 'TypeError: Failed to fetch'
}

Также возможны специальные типы ошибок:

{
  status: 'PARSING_ERROR',
  originalStatus: 200,
  data: 'invalid json',
  error: 'SyntaxError'
}

И:

{
  status: 'TIMEOUT_ERROR',
  error: 'Request timeout'
}

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

RTK Query предоставляет ошибки через hook.

Пример:

const {
  data,
  error,
  isLoading,
  isError
} = useGetUsersQuery()

Если запрос завершился неуспешно:

if (isError) {
  console.log(error)
}

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

Статус ошибки обычно содержит HTTP-код.

Пример:

if (error?.status === 404) {
  console.log('Ресурс не найден')
}

Обработка 401:

if (error?.status === 401) {
  console.log('Необходима авторизация')
}

Обработка 500:

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

Безопасная работа с error

Ошибка может иметь разные структуры.

Небезопасный код:

console.log(error.data.message)

Безопасный вариант:

console.log(error?.data?.message)

Либо:

const message =
  error?.data?.message ||
  error?.error ||
  'Unknown error'

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

Сетевые ошибки возникают, когда сервер недоступен или отсутствует соединение.

Пример:

{
  status: 'FETCH_ERROR',
  error: 'TypeError: Failed to fetch'
}

Проверка:

if (error?.status === 'FETCH_ERROR') {
  console.log('Сетевая ошибка')
}

Обработка parsing errors

Если сервер вернул невалидный JSON, fetchBaseQuery генерирует PARSING_ERROR.

Пример:

if (error?.status === 'PARSING_ERROR') {
  console.log('Ошибка обработки ответа')
}

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

Mutation hooks возвращают Promise со специальной логикой RTK Query.

Без unwrap ошибки не выбрасываются через catch.

Пример:

const [createUser] = useCreateUserMutation()

const handleCreate = async () => {
  try {
    const result = await createUser({
      name: 'Alex'
    }).unwrap()

    console.log(result)
  } catch (error) {
    console.log(error)
  }
}

unwrap():

  • извлекает payload;
  • выбрасывает ошибку;
  • позволяет использовать стандартный try/catch.

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

Mutation hook также предоставляет объект ошибки.

Пример:

const [
  updateUser,
  {
    error,
    isError,
    isLoading
  }
] = useUpdateUserMutation()

Проверка:

if (isError) {
  console.log(error)
}

Асинхронная обработка ошибок

Mutation удобно комбинировать с async/await.

Пример:

const handleSubmit = async () => {
  try {
    await updateUser(data).unwrap()

    console.log('Успешно')
  } catch (error) {
    console.log('Ошибка')
  }
}

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

Крупные приложения редко обрабатывают ошибки прямо внутри компонентов.

Распространённая практика — создание кастомного baseQuery.


Обёртка над fetchBaseQuery

Пример:

import {
  fetchBaseQuery
} from '@reduxjs/toolkit/query/react'

const baseQuery = fetchBaseQuery({
  baseUrl: '/api'
})

const baseQueryWithErrorHandler = async (
  args,
  api,
  extraOptions
) => {
  const result = await baseQuery(
    args,
    api,
    extraOptions
  )

  if (result.error) {
    console.log(result.error)
  }

  return result
}

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

Частый сценарий — автоматический logout.

Пример:

const baseQueryWithAuth = async (
  args,
  api,
  extraOptions
) => {
  const result = await baseQuery(
    args,
    api,
    extraOptions
  )

  if (result.error?.status === 401) {
    api.dispatch(logout())
  }

  return result
}

Автоматическое обновление access token

RTK Query часто используется вместе с refresh token.

Схема работы:

  1. access token истёк;
  2. сервер вернул 401;
  3. отправляется refresh-запрос;
  4. access token обновляется;
  5. оригинальный запрос повторяется.

Пример refresh token логики

const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers, { getState }) => {
    const token = getState().auth.token

    if (token) {
      headers.set(
        'authorization',
        `Bearer ${token}`
      )
    }

    return headers
  }
})

const baseQueryWithReauth = async (
  args,
  api,
  extraOptions
) => {
  let result = await baseQuery(
    args,
    api,
    extraOptions
  )

  if (result.error?.status === 401) {
    const refreshResult = await baseQuery(
      '/auth/refresh',
      api,
      extraOptions
    )

    if (refreshResult.data) {
      api.dispatch(
        setToken(refreshResult.data.token)
      )

      result = await baseQuery(
        args,
        api,
        extraOptions
      )
    } else {
      api.dispatch(logout())
    }
  }

  return result
}

Retry-механизм

RTK Query поддерживает автоматические повторные запросы.

Используется utility retry.

Пример:

import {
  retry
} from '@reduxjs/toolkit/query/react'

const staggeredBaseQuery = retry(
  fetchBaseQuery({
    baseUrl: '/api'
  }),
  {
    maxRetries: 5
  }
)

Логика retry

RTK Query повторяет запросы:

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

Retry особенно полезен:

  • для мобильных сетей;
  • для websocket fallback API;
  • для микросервисной архитектуры;
  • для нестабильных gateway.

Кастомная retry-логика

Пример:

const customBaseQuery = retry(
  fetchBaseQuery({
    baseUrl: '/api'
  }),
  {
    maxRetries: 3
  }
)

Дополнительно можно проверять тип ошибки:

const baseQueryWithConditionalRetry =
  retry(
    async (args, api, extraOptions) => {
      const result = await baseQuery(
        args,
        api,
        extraOptions
      )

      if (
        result.error?.status === 400
      ) {
        retry.fail(result.error)
      }

      return result
    }
  )

Ошибки в transformResponse

Ошибка может возникать не только в HTTP-запросе.

Пример:

transformResponse: (response) => {
  return response.data.items
}

Если response.data отсутствует:

Cannot read properties of undefined

Безопасный вариант:

transformResponse: (response) => {
  return response?.data?.items || []
}

Ошибки в transformErrorResponse

RTK Query позволяет модифицировать объект ошибки.

Пример:

transformErrorResponse: (
  response
) => {
  return {
    status: response.status,
    message:
      response.data?.message ||
      'Server error'
  }
}

Теперь ошибка будет иметь форму:

{
  status: 500,
  message: 'Server error'
}

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

Бэкенды часто возвращают разные структуры ошибок.

Например:

{
  error: 'Validation error'
}

Или:

{
  message: 'Invalid credentials'
}

Нормализация решает проблему несогласованных API.

Пример:

transformErrorResponse: (
  response
) => {
  return {
    status: response.status,
    message:
      response.data?.message ||
      response.data?.error ||
      'Unknown error'
  }
}

Error boundaries и RTK Query

RTK Query не заменяет React Error Boundaries.

Важно понимать различия:

Тип ошибки Error Boundary RTK Query
HTTP ошибки Нет Да
Network ошибки Нет Да
Render ошибки Да Нет
Runtime ошибки Да Нет

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

RTK Query предоставляет специальный флаг:

const {
  isError
} = useGetPostsQuery()

Пример UI:

if (isError) {
  return <div>Error</div>
}

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

Многие backend API возвращают полезную информацию.

Пример:

{
  message: 'Email already exists'
}

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

if (error?.data?.message) {
  console.log(error.data.message)
}

Валидационные ошибки

Сервер может возвращать ошибки формы.

Пример:

{
  errors: {
    email: 'Invalid email',
    password: 'Too short'
  }
}

Обработка:

const validationErrors =
  error?.data?.errors

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

<input />
<span>
  {validationErrors?.email}
</span>

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

RTK Query поддерживает собственную реализацию запросов через queryFn.

Пример:

getUser: builder.query({
  async queryFn(id) {
    try {
      const response =
        await customApi.getUser(id)

      return {
        data: response
      }
    } catch (error) {
      return {
        error: {
          status: 500,
          data: error
        }
      }
    }
  }
})

RejectWithValue и RTK Query

При интеграции с async logic иногда используется rejectWithValue.

Пример:

return {
  error: {
    status: 400,
    data: {
      message: 'Validation failed'
    }
  }
}

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

В TypeScript ошибки RTK Query имеют union-тип.

Пример:

FetchBaseQueryError
| SerializedError

Поэтому часто используются type guards.

Пример:

if ('status' in error) {
  console.log(error.status)
}

SerializedError

Некоторые ошибки являются runtime-ошибками.

Пример:

{
  name: 'TypeError',
  message: 'Failed to fetch'
}

Это объект типа SerializedError.


Устранение дублирования обработки ошибок

Повторяющийся код:

if (error?.status === 401)

лучше выносить:

export const isUnauthorized =
  (error) =>
    error?.status === 401

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

if (isUnauthorized(error)) {
  logout()
}

Middleware для логирования ошибок

Ошибки RTK Query можно перехватывать через middleware.

Пример:

const errorLogger =
  () => (next) => (action) => {
    if (action.error) {
      console.log(action.error)
    }

    return next(action)
  }

toast-уведомления

Часто ошибки отображаются через toast.

Пример:

try {
  await login(data).unwrap()
} catch (error) {
  toast.error(
    error?.data?.message
  )
}

Разделение server errors и client errors

Важно различать:

Тип Диапазон
Client errors 400–499
Server errors 500–599

Пример:

if (error.status >= 500) {
  console.log('Проблема сервера')
}

Пользовательские сообщения ошибок

Сырые backend-сообщения редко подходят для UI.

Нежелательно:

"SQLSTATE[23505]"

Лучше:

const getErrorMessage = (
  error
) => {
  switch (error?.status) {
    case 401:
      return 'Необходим вход'

    case 403:
      return 'Нет доступа'

    case 404:
      return 'Ресурс не найден'

    default:
      return 'Произошла ошибка'
  }
}

AbortError

RTK Query умеет отменять запросы.

Пример:

const promise =
  dispatch(api.endpoints.getUsers.initiate())

promise.abort()

После отмены запрос получает aborted state.


Игнорирование aborted-запросов

Обычно отменённые запросы не считаются ошибками UI.

Пример:

if (error?.name === 'AbortError') {
  return
}

Обработка ошибок при polling

Polling-запросы требуют особого подхода.

Нежелательно показывать popup при каждой ошибке polling.

Распространённая стратегия:

  • логировать ошибки;
  • показывать индикатор offline;
  • повторять запросы;
  • не блокировать UI.

optimistic update и rollback

При optimistic update mutation может завершиться ошибкой.

Пример:

async onQueryStarted(
  arg,
  { dispatch, queryFulfilled }
) {
  const patchResult =
    dispatch(
      api.util.updateQueryData(
        'getPosts',
        undefined,
        (draft) => {
          draft.push(arg)
        }
      )
    )

  try {
    await queryFulfilled
  } catch {
    patchResult.undo()
  }
}

Если mutation завершится ошибкой, изменения откатываются.


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

В production ошибки обычно отправляются:

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

Пример:

if (result.error) {
  Sentry.captureException(
    result.error
  )
}

Практика организации error handling

Типичная архитектура:

  • baseQueryWithReauth
  • transformErrorResponse
  • toast middleware
  • utility helpers
  • retry logic
  • centralized logging
  • rollback optimistic updates

Такой подход обеспечивает:

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