Retry стратегии

Сетевые запросы нестабильны по своей природе. Даже корректно написанный backend может временно возвращать ошибки из-за перегрузки, проблем сети, балансировщиков, таймаутов или ограничений API. TanStack Query содержит встроенный механизм повторных запросов, который автоматически пытается повторить неудачный запрос без участия пользователя.

Retry-механизм особенно полезен в следующих ситуациях:

  • временные ошибки сети;
  • кратковременная недоступность сервиса;
  • нестабильный мобильный интернет;
  • API с rate limiting;
  • ошибки CDN и gateway;
  • intermittent failures.

При этом неправильная стратегия retry способна привести к противоположному эффекту:

  • перегрузке backend;
  • бесконечным повторным запросам;
  • ухудшению UX;
  • DDoS-подобному поведению клиента;
  • лишнему расходу трафика.

Базовый retry

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

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

Если fetchPosts() завершится ошибкой, библиотека выполнит повторный запрос автоматически.

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

  • retry: 3
  • exponential backoff delay
  • retry работает только для queries
  • mutations retry не выполняют автоматически

Как работает retry

Алгоритм выглядит следующим образом:

  1. Выполняется запрос

  2. Если запрос успешен — данные сохраняются

  3. Если произошла ошибка:

    • ошибка сохраняется;
    • query получает статус error;
    • запускается retry delay;
    • выполняется повторный запрос
  4. Если retries исчерпаны — ошибка окончательно попадает в компонент


Количество повторов

Количество retry настраивается через свойство retry.

Retry отключён

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

При первой ошибке запрос завершится окончательно.

Такой подход полезен:

  • для 404;
  • для validation errors;
  • для business logic errors;
  • для приватных ресурсов;
  • для ошибок авторизации.

Ограниченное число retry

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

TanStack Query выполнит максимум 5 повторных запросов.


Retry как функция

Наиболее гибкий вариант — использование callback-функции.

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

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

        return failureCount < 3
    },
})

Аргументы:

Аргумент Описание
failureCount количество ошибок подряд
error объект ошибки

Это позволяет строить сложные стратегии retry.


Retry только для сетевых ошибок

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

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    retry: (count, error) => {
        return error.code === 'NETWORK_ERROR' && count < 5
    },
})

Retry выполняется исключительно при проблемах сети.


Исключение retry для 4xx ошибок

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

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

    return count < 3
}

Причины:

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

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


Retry для 5xx ошибок

Ошибки сервера чаще являются временными.

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

Особенно полезно для:

  • 502 Bad Gateway;
  • 503 Service Unavailable;
  • 504 Gateway Timeout.

Retry delay

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

Настройка:

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

Каждый retry будет ожидать 1 секунду.


Retry delay как функция

Более мощный вариант.

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

Поведение:

Попытка Delay
1 1000ms
2 2000ms
3 3000ms

Exponential Backoff

Наиболее популярная стратегия retry.

С каждым retry задержка увеличивается экспоненциально.

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

Логика:

Попытка Delay
1 2000ms
2 4000ms
3 8000ms
4 16000ms
5 30000ms

Такая стратегия:

  • уменьшает нагрузку;
  • предотвращает retry storms;
  • даёт backend время восстановиться;
  • улучшает стабильность распределённых систем.

Linear Backoff

Линейное увеличение задержки.

retryDelay: (attempt) => attempt * 2000

Пример:

Попытка Delay
1 2s
2 4s
3 6s

Используется реже, чем exponential backoff.


Fixed Delay

Фиксированный интервал.

retryDelay: 3000

Каждый retry выполняется через 3 секунды.

Подходит:

  • для polling API;
  • для локальных сервисов;
  • для простых приложений.

Randomized Delay и jitter

Если тысячи клиентов начинают retry одновременно, возникает эффект retry storm.

Для предотвращения используют jitter.

retryDelay: (attempt) => {
    const baseDelay = 1000 * 2 ** attempt
    const jitter = Math.random() * 1000

    return baseDelay + jitter
}

Преимущества:

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

Глобальная retry стратегия

Настройка через QueryClient.

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: 3,
            retryDelay: (attempt) => {
                return Math.min(1000 * 2 ** attempt, 30000)
            },
        },
    },
})

Все queries автоматически получают общую стратегию.


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

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

useQuery({
    queryKey: ['critical-data'],
    queryFn: fetchCriticalData,
    retry: 10,
})

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


Retry и offline mode

TanStack Query умеет учитывать состояние сети.

При offline-состоянии retry может быть отложен до восстановления соединения.

networkMode: 'online'

Режимы:

Режим Поведение
online запросы только при наличии сети
always игнорирует offline
offlineFirst оптимизация под offline

Retry при восстановлении соединения

После reconnect query может автоматически перезапуститься.

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

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


Retry и focus refetch

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

refetchOnWindowFocus: true

Комбинация:

  • retry;
  • reconnect refetch;
  • focus refetch;

создаёт очень устойчивую систему загрузки данных.


Retry и timeout

Retry не заменяет timeout.

Без timeout запрос может зависнуть навсегда.

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

const fetchPosts = async () => {
    const controller = new AbortController()

    const timeout = setTimeout(() => {
        controller.abort()
    }, 5000)

    try {
        const response = await fetch('/api/posts', {
            signal: controller.signal,
        })

        return response.json()
    } finally {
        clearTimeout(timeout)
    }
}

Retry будет работать только после завершения timeout.


Retry и AbortSignal

TanStack Query автоматически передаёт signal.

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

    return response.json()
}

Это предотвращает:

  • утечки памяти;
  • дублирующиеся запросы;
  • race conditions.

Retry и mutations

Mutations по умолчанию retry не используют.

Причина — mutation может быть неидемпотентной.

Например:

createOrder()

Повторный вызов способен:

  • создать дубликат заказа;
  • повторно списать деньги;
  • отправить duplicate email.

Retry для mutations

Тем не менее retry можно включить вручную.

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

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

  • PUT;
  • PATCH;
  • idempotent APIs;
  • safe operations.

Retry для POST запросов

POST требует осторожности.

Плохой пример:

retry: 5

Без idempotency backend может выполнить операцию несколько раз.

Правильные решения:

  • idempotency keys;
  • transaction IDs;
  • deduplication;
  • server-side locking.

Retry и rate limiting

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

429 Too Many Requests

В таких случаях retry должен учитывать заголовки сервера.

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

Retry-After header

Backend может сообщать рекомендуемую задержку.

Retry-After: 30

Можно использовать её динамически.

retryDelay: (_, error) => {
    return error.retryAfter * 1000
}

Пользовательские retry стратегии

Иногда retry зависит от бизнес-логики.

Retry только ночью

retry: () => {
    const hour = new Date().getHours()

    return hour >= 0 && hour <= 6
}

Retry только для premium API

retry: (_, error) => {
    return error.isPremiumEndpoint
}

Retry в зависимости от браузера

retry: () => {
    return navigator.onLine
}

Retry и UX

Агрессивный retry способен ухудшить интерфейс.

Проблемы:

  • бесконечные loader;
  • мигающий UI;
  • скачущие состояния;
  • постоянные spinners.

Retry и отображение ошибок

Важно понимать: query может находиться в error state, но retry всё ещё продолжается.

Полезные свойства:

const {
    error,
    failureCount,
    failureReason,
    isError,
} = useQuery(...)

failureCount

Показывает число failed attempts.

if (failureCount > 0) {
    return <RetryInfo />
}

failureReason

Содержит последнюю ошибку retry.

console.log(failureReason)

Полезно для debugging.


Отображение retry пользователю

Иногда интерфейс показывает:

Повторное подключение...
Попытка 2 из 5

Пример:

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

if (query.failureCount > 0) {
    return (
        <div>
            Retry attempt: {query.failureCount}
        </div>
    )
}

Retry и Suspense

При использовании Suspense retry становится особенно важным.

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

Suspense не показывает ошибку мгновенно — query сначала проходит через retry pipeline.


Retry и Error Boundary

После исчерпания retry ошибка может попасть в Error Boundary.

throwOnError: true

Комбинация:

retry: 3
throwOnError: true

означает:

  1. Выполнить 3 retry
  2. Если всё ещё ошибка — передать её в boundary

Retry и background refetch

Background refetch также использует retry.

Например:

refetchInterval: 30000

Если background refetch падает:

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

Это один из ключевых механизмов resilience в TanStack Query.


Retry storm

Опасная ситуация в distributed systems.

Сценарий:

  1. Backend падает
  2. Тысячи клиентов начинают retry
  3. Сервер получает лавину запросов
  4. Восстановление становится невозможным

Методы защиты:

  • exponential backoff;
  • jitter;
  • ограничение retry count;
  • circuit breaker;
  • request queueing.

Circuit Breaker Pattern

Иногда retry нужно временно отключать.

Пример идеи:

let serverUnavailable = false

retry: (count, error) => {
    if (serverUnavailable) {
        return false
    }

    if (error.status === 503) {
        serverUnavailable = true

        setTimeout(() => {
            serverUnavailable = false
        }, 60000)
    }

    return count < 3
}

Подход предотвращает каскадные сбои.


Оптимальная retry стратегия

Наиболее практичная production-конфигурация:

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

                return count < 3
            },

            retryDelay: (attempt) => {
                const delay = 1000 * 2 ** attempt
                const jitter = Math.random() * 1000

                return Math.min(delay + jitter, 30000)
            },
        },
    },
})

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

  • отсутствует retry для 4xx;
  • exponential backoff;
  • jitter;
  • ограничение максимальной задержки;
  • защита backend;
  • хорошая устойчивость к сетевым сбоям.