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

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

Наиболее распространённые категории серверных ошибок:

  • ошибки авторизации (401 Unauthorized);
  • ошибки доступа (403 Forbidden);
  • отсутствие ресурса (404 Not Found);
  • конфликты данных (409 Conflict);
  • ошибки валидации (422 Unprocessable Entity);
  • внутренние ошибки сервера (500 Internal Server Error);
  • временная недоступность API (503 Service Unavailable).

TanStack Query рассматривает ошибку как отдельное состояние запроса, наряду с:

  • pending;
  • success;
  • error.

Каждый query и mutation содержит собственный набор флагов и данных ошибок.


Базовая обработка ошибок в useQuery

Простейшая обработка ошибок строится через свойства error и isError.

import { useQuery } from '@tanstack/react-query'

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

  if (!response.ok) {
    throw new Error('Ошибка загрузки пользователей')
  }

  return response.json()
}

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

  if (isPending) {
    return <div>Загрузка...</div>
  }

  if (isError) {
    return <div>{error.message}</div>
  }

  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

Почему fetch не выбрасывает ошибки автоматически

API fetch() не считает HTTP-статусы 404, 500, 403 ошибками JavaScript. Ошибка выбрасывается только при сетевых сбоях.

Неверный вариант:

async function fetchPosts() {
  const response = await fetch('/api/posts')
  return response.json()
}

Даже при 500 Internal Server Error код попадёт в success.

Правильный подход:

async function fetchPosts() {
  const response = await fetch('/api/posts')

  if (!response.ok) {
    throw new Error(`HTTP Error: ${response.status}`)
  }

  return response.json()
}

Формирование собственного объекта ошибки

Стандартный Error содержит слишком мало информации. На практике сервер часто возвращает JSON с описанием проблемы.

Например:

{
  "message": "Email already exists",
  "code": "EMAIL_EXISTS"
}

Правильнее формировать расширенную ошибку:

async function registerUser(payload) {
  const response = await fetch('/api/register', {
    method: 'POST',
    body: JSON.stringify(payload),
    headers: {
      'Content-Type': 'application/json'
    }
  })

  if (!response.ok) {
    const errorData = await response.json()

    const error = new Error(errorData.message)

    error.code = errorData.code
    error.status = response.status

    throw error
  }

  return response.json()
}

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

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

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

Mutation используется для изменения данных на сервере:

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

Ошибки mutation возникают значительно чаще, чем ошибки query.

import { useMutation } from '@tanstack/react-query'

function CreatePost() {
  const mutation = useMutation({
    mutationFn: async (payload) => {
      const response = await fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(payload),
        headers: {
          'Content-Type': 'application/json'
        }
      })

      if (!response.ok) {
        const error = await response.json()
        throw new Error(error.message)
      }

      return response.json()
    }
  })

  return (
    <button
      onCl ick={() => {
        mutation.mutate({
          title: 'New post'
        })
      }}
    >
      Создать
    </button>
  )
}

Состояния ошибок mutation

Mutation предоставляет отдельные флаги:

const {
  mutate,
  isPending,
  isSuccess,
  isError,
  error
} = useMutation(...)

Пример:

if (isError) {
  return <div>{error.message}</div>
}

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

Колбэк onError позволяет централизованно реагировать на ошибки.

const mutation = useMutation({
  mutationFn: saveUser,

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

    showNotification(error.message)
  }
})

onError полезен для:

  • уведомлений;
  • логирования;
  • аналитики;
  • rollback optimistic updates;
  • перенаправлений.

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

Общая обработка ошибок позволяет не дублировать код.

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

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,

      onError: (error) => {
        console.error('Query error:', error)
      }
    },

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

Retry-механизм

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

По умолчанию:

retry: 3

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

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

Отключение retry

Для некоторых ошибок повторные запросы бессмысленны.

Например:

  • 401;
  • 403;
  • 404;
  • ошибки валидации.
useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  retry: false
})

Условный retry

Можно динамически определять необходимость повторного запроса.

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

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

    return failureCount < 3
  }
})

Retry Delay

Настройка задержки между повторными попытками.

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,

  retryDelay: 2000
})

Динамическая задержка:

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

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


Error Boundary

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

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  throwOnError: true
})

Далее ошибка передаётся в boundary:

<ErrorBoundary fallback={<ErrorPage />}>
  <PostsPage />
</ErrorBoundary>

throwOnError

Параметр throwOnError управляет тем, выбрасывается ли ошибка в React.

throwOnError: true

Также поддерживается функция:

throwOnError: (error) => {
  return error.status >= 500
}

Например:

  • ошибки 500 уходят в Error Boundary;
  • ошибки 422 отображаются локально.

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

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

  • ошибки интерфейса;
  • ошибки сети;
  • ошибки API.

Пример неправильного подхода:

try {
  mutate(data)
} catch (error) {
  console.log(error)
}

mutate() не выбрасывает ошибки синхронно.

Правильный вариант:

mutate(data, {
  onError: (error) => {
    console.log(error)
  }
})

mutateAsync и try/catch

Для async/await используется mutateAsync.

const mutation = useMutation({
  mutationFn: loginUser
})

async function handleSubmit() {
  try {
    await mutation.mutateAsync({
      email,
      password
    })
  } catch (error) {
    console.log(error.message)
  }
}

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

Типичный сценарий:

async function fetchProfile() {
  const response = await fetch('/api/profile')

  if (response.status === 401) {
    logout()
    redirectToLogin()
  }

  if (!response.ok) {
    throw new Error('Server error')
  }

  return response.json()
}

Централизованный API-клиент

На практике ошибки удобнее обрабатывать в одном месте.

export async function api(url, options = {}) {
  const response = await fetch(url, options)

  let data = null

  try {
    data = await response.json()
  } catch {}

  if (!response.ok) {
    const error = new Error(
      data?.message || 'Unknown server error'
    )

    error.status = response.status
    error.data = data

    throw error
  }

  return data
}

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

useQuery({
  queryKey: ['posts'],
  queryFn: () => api('/api/posts')
})

Обработка ошибок валидации формы

Сервер может вернуть ошибки полей.

Пример ответа:

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

Mutation:

const mutation = useMutation({
  mutationFn: registerUser
})

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

if (mutation.isError) {
  console.log(mutation.error.data.errors.email)
}

Ошибки optimistic update

При optimistic update данные изменяются до ответа сервера.

Если запрос завершится ошибкой, требуется rollback.

const mutation = useMutation({
  mutationFn: updateTodo,

  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({
      queryKey: ['todos']
    })

    const previousTodos =
      queryClient.getQueryData(['todos'])

    queryClient.setQueryData(['todos'], old => {
      return [...old, newTodo]
    })

    return { previousTodos }
  },

  onError: (error, variables, context) => {
    queryClient.setQueryData(
      ['todos'],
      context.previousTodos
    )
  },

  onSettled: () => {
    queryClient.invalidateQueries({
      queryKey: ['todos']
    })
  }
})

Ошибки и invalidateQueries

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

Неправильный вариант:

onSettled: () => {
  queryClient.invalidateQueries({
    queryKey: ['posts']
  })
}

Даже при ошибке произойдёт refetch.

Лучше:

onSuccess: () => {
  queryClient.invalidateQueries({
    queryKey: ['posts']
  })
}

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

Иногда сервер вообще недоступен.

TypeError: Failed to fetch

Полезно разделять:

if (error instanceof TypeError) {
  console.log('Network error')
}

staleTime и ошибки

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

useQuery({
  queryKey: ['dashboard'],
  queryFn: fetchDashboard,
  staleTime: 60000
})

Поведение:

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

Это особенно важно для dashboard-интерфейсов.


keepPreviousData и ошибки пагинации

При пагинации можно сохранять предыдущую страницу при ошибке загрузки новой.

useQuery({
  queryKey: ['posts', page],
  queryFn: () => fetchPosts(page),
  placeholderData: keepPreviousData
})

Если новая страница не загрузилась:

  • старая страница остаётся видимой;
  • пользователь не получает пустой экран.

Suspense и ошибки

При использовании Suspense ошибки передаются в Error Boundary.

useSuspenseQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts
})

Пример:

<Suspense fallback={<Loader />}>
  <ErrorBoundary fallback={<ErrorPage />}>
    <Posts />
  </ErrorBoundary>
</Suspense>

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

Часто используется интеграция с:

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

Пример:

onError: (error) => {
  Sentry.captureException(error)
}

Кастомный класс ошибки

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

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

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

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

throw new ApiError(
  data.message,
  response.status,
  data
)

Проверка:

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

Обработка 429 Too Many Requests

API может ограничивать частоту запросов.

retry: (count, error) => {
  if (error.status === 429) {
    return count < 5
  }

  return false
}

Иногда сервер возвращает Retry-After.

const retryAfter =
  response.headers.get('Retry-After')

Паттерн fail-fast

Для критических ошибок иногда отключают повторные попытки.

useQuery({
  queryKey: ['payment'],
  queryFn: processPayment,
  retry: false
})

Особенно важно для:

  • финансовых операций;
  • платежей;
  • одноразовых транзакций.

Работа с background refetch errors

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

TanStack Query хранит:

  • старые данные;
  • ошибку обновления.

Пример:

const query = useQuery({
  queryKey: ['stats'],
  queryFn: fetchStats,
  refetchInterval: 5000
})

Даже если один из refetch завершится ошибкой:

  • data останется доступным;
  • error обновится;
  • интерфейс продолжит работать.

select и ошибки трансформации

Ошибка может возникнуть не только в queryFn, но и в select.

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

  select: (data) => {
    return data.users.map(user => ({
      id: user.id,
      name: user.profile.name
    }))
  }
})

Если profile отсутствует, возникнет runtime error.

Следует защищать преобразования:

select: (data) => {
  return data.users.map(user => ({
    id: user.id,
    name: user.profile?.name ?? 'Unknown'
  }))
}

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

По умолчанию ошибка имеет тип unknown.

if (error instanceof Error) {
  console.log(error.message)
}

Для кастомных ошибок:

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

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

Во время SSR ошибка запроса может привести к падению рендера.

Пример:

await queryClient.prefetchQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts
})

Без try/catch серверный рендер может завершиться исключением.

Правильный вариант:

try {
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  })
} catch (error) {
  console.error(error)
}

Предотвращение бесконечных циклов ошибок

Неправильная логика retry может создать DDoS собственного API.

Опасный вариант:

retry: true

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

Безопасный подход:

retry: 3

или:

retry: (count) => count < 5

Архитектурный подход к обработке ошибок

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

API-уровень

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

Query-уровень

  • retry;
  • кеширование;
  • rollback;
  • refetch.

UI-уровень

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

Такое разделение уменьшает связность и упрощает поддержку приложения.