Retry логика

Сетевые запросы нестабильны по своей природе. Даже корректно работающий сервер может временно отвечать ошибками из-за:

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

Если приложение немедленно переводит запрос в состояние ошибки после первой неудачи, пользователь получает нестабильный интерфейс даже при кратковременных сбоях. TanStack Query решает эту проблему встроенной retry-логикой.

Retry — это механизм автоматического повторного выполнения query после ошибки.

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


Поведение retry по умолчанию

Стандартное поведение библиотеки:

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

Внутри автоматически используется:

retry: 3

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

  1. Первый запрос выполняется.
  2. При ошибке TanStack Query повторяет запрос.
  3. Повтор происходит максимум 3 раза.
  4. Если все попытки завершились ошибкой — query получает статус error.

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

retry: 3

означает:

1 основной запрос + 3 повторные попытки

Итого — до 4 запросов.


Пример retry в работе

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

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

    return response.json()
}

function Users() {
    const query = useQuery({
        queryKey: ['users'],
        queryFn: fetchUsers
    })

    if (query.isPending) {
        return <div>Loading...</div>
    }

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

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

Если сервер временно вернул:

500 Internal Server Error

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

Пользователь может даже не заметить кратковременный сбой.


Полное отключение retry

Иногда повторные запросы не нужны.

Например:

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

Retry отключается так:

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    retry: false
})

Теперь запрос выполняется только один раз.


Ограничение количества повторов

Количество попыток можно изменить:

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    retry: 5
})

Теперь TanStack Query выполнит:

1 основной запрос + 5 повторов

Retry как функция

Наиболее мощный вариант — использование функции.

Она позволяет принимать решение о повторе динамически.


Аргументы retry-функции

Retry-функция получает:

retry(failureCount, error)

Где:

  • failureCount — количество ошибок подряд;
  • error — объект ошибки.

Пример retry-функции

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    retry: (failureCount, error) => {
        if (error.status === 404) {
            return false
        }

        return failureCount < 3
    }
})

Логика:

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

Почему 404 обычно не retry-ится

Ошибка 404 Not Found означает:

Ресурс не существует

Повторный запрос обычно не имеет смысла.

Например:

/api/posts/999999

Если записи нет — повтор не поможет.


Ошибки, подходящие для retry

Повторные запросы полезны при:

Код Причина
500 Временная ошибка сервера
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
Network Error Потеря сети
ECONNRESET Разрыв соединения
ETIMEDOUT Таймаут

Ошибки, которые обычно не retry-ятся

Код Причина
400 Неверный запрос
401 Нет авторизации
403 Доступ запрещён
404 Ресурс отсутствует
422 Ошибка валидации

RetryDelay

Между повторными запросами TanStack Query делает паузу.

Эта задержка управляется параметром:

retryDelay

Поведение retryDelay по умолчанию

TanStack Query использует exponential backoff.

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

Примерно так:

1 попытка → 1 секунда
2 попытка → 2 секунды
3 попытка → 4 секунды

Это снижает нагрузку на сервер.


Собственный retryDelay

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    retryDelay: 2000
})

Теперь между попытками всегда:

2 секунды

RetryDelay как функция

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    retryDelay: attemptIndex => {
        return attemptIndex * 1000
    }
})

Результат:

1 попытка → 1 секунда
2 попытка → 2 секунды
3 попытка → 3 секунды

Exponential Backoff

Наиболее правильная стратегия retry — постепенное увеличение задержки.

Пример:

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

Механизм:

1 → 1000ms
2 → 2000ms
3 → 4000ms
4 → 8000ms
5 → 16000ms

Максимум:

30000ms

Почему exponential backoff важен

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

  • перегрузить API;
  • создать DDoS-подобное поведение;
  • вызвать rate limit;
  • ухудшить состояние сервера.

Особенно критично при массовых запросах.


Retry и пользовательский интерфейс

Во время повторных попыток query всё ещё находится в loading-состоянии.

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

query.isPending === true

Пока retry продолжается:

query.isError === false

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


Пример состояния во время retry

function Todos() {
    const query = useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
        retry: 3
    })

    console.log(query.status)

    return null
}

Возможная последовательность:

pending
pending
pending
success

или:

pending
pending
pending
error

failureCount

TanStack Query хранит количество неудачных попыток:

query.failureCount

Пример:

function Posts() {
    const query = useQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts
    })

    return (
        <div>
            Failures: {query.failureCount}
        </div>
    )
}

failureReason

Последняя ошибка хранится в:

query.failureReason

Пример:

<div>
    {query.failureReason?.message}
</div>

Retry и offline-режим

TanStack Query умеет определять потерю сети.

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

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

Это особенно важно для мобильных приложений.


Retry при focus/refetch

Retry не связан напрямую с:

  • refetchOnWindowFocus;
  • refetchOnReconnect;
  • refetchInterval.

Но они могут запускать новые запросы, внутри которых снова работает retry-механизм.


Retry в глобальных настройках

Retry можно настроить глобально через QueryClient.

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

Теперь все query используют:

retry: 2

Глобальный retryDelay

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retryDelay: 1000
        }
    }
})

Локальное переопределение

Локальные настройки имеют приоритет.

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

Даже если глобально:

retry: 5

Retry и Axios

При использовании Axios важно выбрасывать ошибки.

Axios делает это автоматически.

import axios fr om 'axios'

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

    return response.data
}

Если сервер вернёт:

500

Axios выбросит exception, и retry сработает.


Retry и Fetch API

Fetch API не выбрасывает ошибки для HTTP-кодов.

Нужно делать это вручную.

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

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

    return response.json()
}

Правильно:

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

    if (!response.ok) {
        throw new Error('Request failed')
    }

    return response.json()
}

Иначе retry не активируется.


Retry при rate limit

Некоторые API возвращают:

429 Too Many Requests

В этом случае retry должен быть осторожным.

Пример:

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

    return failureCount < 3
}

Retry и idempotency

Повторные запросы безопасны не всегда.

GET-запросы обычно безопасны:

GET /posts

Но mutation может вызвать проблемы:

POST /payment

Повтор может создать:

  • двойную оплату;
  • повторный заказ;
  • дублирование данных.

Поэтому retry чаще применяется к query, а не mutation.


Retry в useMutation

Mutation тоже поддерживает retry.

useMutation({
    mutationFn: createPost,
    retry: 2
})

Но использовать retry для mutation нужно осторожно.


Retry и отмена запроса

Если query отменяется:

queryClient.cancelQueries()

retry-цепочка тоже прекращается.


Retry и AbortController

Если queryFn поддерживает signal:

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

    return response.json()
}

TanStack Query сможет корректно прерывать retry.


Типичная production-стратегия

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

retry: (failureCount, error) => {
    if (error.status >= 400 && error.status < 500) {
        return false
    }

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

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

  • client errors не retry-ятся;
  • server errors retry-ятся;
  • используется exponential backoff;
  • ограничивается максимальная задержка.

Антипаттерны retry

Бесконечный retry

retry: true

Это может привести к бесконечным запросам.

Особенно опасно при падении API.


Retry без задержки

retryDelay: 0

Создаёт огромную нагрузку.


Retry для 401

401 Unauthorized

Повтор не поможет, пока пользователь не авторизуется.


Retry тяжёлых запросов

Некоторые запросы:

  • долго выполняются;
  • дорого стоят;
  • создают большую нагрузку.

Retry может многократно ухудшить ситуацию.


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

const query = useQuery({
    queryKey: ['feed'],
    queryFn: fetchFeed,

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

        if (error.status === 401) {
            return false
        }

        return failureCount < 3
    },

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

Поведение:

  • 404 не повторяется;
  • 401 не повторяется;
  • остальные ошибки повторяются;
  • задержка постепенно растёт;
  • максимальная пауза — 10 секунд.