Сетевые запросы не гарантируют стабильный результат. Сервер может быть временно недоступен, соединение может оборваться, балансировщик может вернуть ошибку 502, а API — ответить кодом 503 во время обновления инфраструктуры. В подобных ситуациях повторный запрос способен успешно завершиться спустя несколько миллисекунд или секунд.
RTK Query предоставляет встроенный механизм повторных запросов — retry logic. Он позволяет автоматически повторять неудачные запросы без ручной реализации циклов, таймеров и промежуточных состояний.
Retry-механизм особенно полезен для:
Retry-логика представляет собой обёртку над
baseQuery.
Стандартная схема выглядит следующим образом:
import { fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react';
const baseQuery = fetchBaseQuery({
baseUrl: '/api'
});
const baseQueryWithRetry = retry(baseQuery);
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithRetry,
endpoints: () => ({})
});
В данном примере:
Наиболее распространённая комбинация:
import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react';
const staggeredBaseQuery = retry(
fetchBaseQuery({
baseUrl: 'https://example.com/api'
})
);
export const api = createApi({
reducerPath: 'api',
baseQuery: staggeredBaseQuery,
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts'
})
})
});
RTK Query не изменяет логику самого fetchBaseQuery.
Retry лишь перезапускает его при ошибках.
По умолчанию retry выполняет до 5 попыток.
Эквивалентная запись:
const baseQueryWithRetry = retry(baseQuery, {
maxRetries: 5
});
Изменение количества повторов:
const baseQueryWithRetry = retry(baseQuery, {
maxRetries: 3
});
Теперь последовательность будет такой:
RTK Query использует exponential backoff — экспоненциальное увеличение задержки.
Типичная схема:
| Попытка | Пример задержки |
|---|---|
| 1 | 600ms |
| 2 | 1200ms |
| 3 | 2400ms |
| 4 | 4800ms |
Такой подход предотвращает мгновенную перегрузку сервера.
Экспоненциальная задержка считается стандартом в сетевых системах.
Простейшая формула:
y = baseDelay ^n
Где:
baseDelay — начальная задержка;n — номер повторной попытки.RTK Query автоматически реализует подобную стратегию.
Полная конфигурация:
const baseQueryWithRetry = retry(baseQuery, {
maxRetries: 5
});
Хотя встроенная конфигурация минималистична, её обычно достаточно для большинства приложений.
Повторные запросы полезны не всегда.
Например:
| Код | Нужно ли повторять |
|---|---|
| 500 | Да |
| 502 | Да |
| 503 | Да |
| 504 | Да |
| 401 | Нет |
| 403 | Нет |
| 404 | Обычно нет |
| 422 | Нет |
Ошибки авторизации или валидации не исчезают автоматически, поэтому retry для них бесполезен.
Для более тонкого контроля создаётся собственный
baseQuery.
Пример:
import {
createApi,
fetchBaseQuery,
retry
} from '@reduxjs/toolkit/query/react';
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api'
});
const baseQuery = retry(
async (args, api, extraOptions) => {
const result = await rawBaseQuery(
args,
api,
extraOptions
);
if (result.error) {
const status = result.error.status;
if (
status === 401 ||
status === 403
) {
retry.fail(result.error);
}
}
return result;
},
{
maxRetries: 5
}
);
export const api = createApi({
reducerPath: 'api',
baseQuery,
endpoints: () => ({})
});
Метод retry.fail() немедленно прекращает цепочку
повторов.
Это особенно полезно для:
Пример:
retry.fail(result.error);
После вызова:
Одна из самых распространённых задач — повтор запроса после обновления access token.
Сценарий:
Пример:
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers) => {
const token = localStorage.getItem('token');
if (token) {
headers.set(
'Authorization',
`Bearer ${token}`
);
}
return headers;
}
});
const baseQueryWithReauth = async (
args,
api,
extraOptions
) => {
let result = await rawBaseQuery(
args,
api,
extraOptions
);
if (result.error?.status === 401) {
const refreshResult = await rawBaseQuery(
{
url: '/refresh',
method: 'POST'
},
api,
extraOptions
);
if (refreshResult.data) {
localStorage.setItem(
'token',
refreshResult.data.token
);
result = await rawBaseQuery(
args,
api,
extraOptions
);
}
}
return result;
};
const baseQuery = retry(
baseQueryWithReauth,
{
maxRetries: 3
}
);
Важно понимать последовательность:
Основной запрос
↓
401 Unauthorized
↓
Refresh token
↓
Повтор исходного запроса
↓
При неудаче — retry
Если refresh token тоже завершится ошибкой, можно вызвать:
retry.fail(refreshResult.error);
RTK Query корректно обрабатывает:
Пример ошибки:
{
status: 'FETCH_ERROR',
error: 'TypeError: Failed to fetch'
}
Retry особенно полезен именно для подобных временных проблем.
Часто retry комбинируют с таймаутами.
Пример собственного timeout-wrapper:
const baseQueryWithTimeout = async (
args,
api,
extraOptions
) => {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 5000);
try {
return await fetchBaseQuery({
baseUrl: '/api'
})(
{
...args,
signal: controller.signal
},
api,
extraOptions
);
} finally {
clearTimeout(timeout);
}
};
const baseQuery = retry(
baseQueryWithTimeout,
{
maxRetries: 3
}
);
Retry не заменяет polling.
Разница:
| Retry | Polling |
|---|---|
| Повторяет ошибку | Выполняет периодический запрос |
| Реагирует на failure | Работает постоянно |
| Используется при временных сбоях | Используется для обновления данных |
Polling:
useGetPostsQuery(undefined, {
pollingInterval: 10000
});
Retry:
retry(baseQuery)
Эти механизмы часто используются совместно.
Retry не влияет на cache напрямую.
Последовательность:
Ошибки не кешируются как данные.
Retry работает до завершения mutation.
Пример:
updatePost: builder.mutation({
query: (body) => ({
url: `/posts/${body.id}`,
method: 'PUT',
body
}),
invalidatesTags: ['Posts']
})
Если mutation временно завершится ошибкой:
При optimistic upd ate важно учитывать возможные повторные запросы.
Пример:
async onQueryStarted(
arg,
{ dispatch, queryFulfilled }
) {
const patchResult = dispatch(
api.util.updateQueryData(
'getPosts',
undefined,
(draft) => {
draft.push(arg);
}
)
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
}
Если retry eventually succeeds:
Если retries исчерпаны:
Abort полностью отменяет retry-цепочку.
Пример:
const promise = dispatch(
api.endpoints.getPosts.initiate()
);
promise.abort();
После abort:
В React Strict Mode некоторые эффекты вызываются дважды в development-режиме.
Это может привести к:
Однако production-сборка работает корректно.
При SSR повторные запросы требуют осторожности.
Причины:
На сервере retry часто ограничивают:
const baseQuery = retry(baseQueryRaw, {
maxRetries: 1
});
Retry относится исключительно к HTTP-запросам через
baseQuery.
WebSocket reconnect реализуется отдельно.
Retry не управляет:
На практике обычно используются такие настройки:
| Тип запроса | Retry |
|---|---|
| GET | 3–5 |
| POST | 1–2 |
| PUT | 1–2 |
| DELETE | 0–1 |
| Авторизация | Осторожно |
| Upload файлов | Минимально |
Слишком агрессивные повторы способны вызвать:
Особенно опасен retry для:
Retry безопаснее для идемпотентных операций.
Идемпотентность означает:
повторный запрос приводит к тому же результату.
Примеры:
| Метод | Обычно идемпотентен |
|---|---|
| GET | Да |
| PUT | Да |
| DELETE | Да |
| POST | Часто нет |
POST требует особой осторожности.
Проблемный сценарий:
POST /orders
Сервер:
Клиент считает запрос неудачным и повторяет POST.
Результат:
Для безопасного retry POST-запросов применяют:
Пример:
headers.se t(
'Idempotency-Key',
crypto.randomUUID()
);
В микросервисах retry особенно важен из-за:
RTK Query помогает скрывать кратковременные сбои от UI.
Автоматические повторы улучшают пользовательский опыт:
Однако слишком долгий retry способен ухудшать UX из-за длительного ожидания.
Во время retry endpoint остаётся в loading-состоянии.
Например:
const {
data,
error,
isLoading,
isFetching
} = useGetPostsQuery();
Пока идут retries:
isLoading = trueisFetching = trueerror = undefinedОшибка появится только после окончательного провала.
В Redux DevTools можно наблюдать:
Это полезно при отладке retry-поведения.
Пример комплексной реализации:
import {
createApi,
fetchBaseQuery,
retry
} from '@reduxjs/toolkit/query/react';
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers) => {
const token = localStorage.getItem('token');
if (token) {
headers.se t(
'Authorization',
`Bearer ${token}`
);
}
headers.set(
'Content-Type',
'application/json'
);
return headers;
}
});
const customBaseQuery = async (
args,
api,
extraOptions
) => {
const result = await rawBaseQuery(
args,
api,
extraOptions
);
if (result.error) {
const status = result.error.status;
if (
status === 401 ||
status === 403 ||
status === 422
) {
retry.fail(result.error);
}
}
return result;
};
const baseQuery = retry(
customBaseQuery,
{
maxRetries: 3
}
);
export const api = createApi({
reducerPath: 'api',
baseQuery,
tagTypes: ['Posts'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Posts']
}),
createPost: builder.mutation({
query: (body) => ({
url: '/posts',
method: 'POST',
body
}),
invalidatesTags: ['Posts']
})
})
});