Retry логика

Сетевые запросы не гарантируют стабильный результат. Сервер может быть временно недоступен, соединение может оборваться, балансировщик может вернуть ошибку 502, а API — ответить кодом 503 во время обновления инфраструктуры. В подобных ситуациях повторный запрос способен успешно завершиться спустя несколько миллисекунд или секунд.

RTK Query предоставляет встроенный механизм повторных запросов — retry logic. Он позволяет автоматически повторять неудачные запросы без ручной реализации циклов, таймеров и промежуточных состояний.

Retry-механизм особенно полезен для:

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

Базовый принцип работы 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: () => ({})
});

В данном примере:

  1. Выполняется основной запрос.
  2. При ошибке RTK Query автоматически запускает повтор.
  3. Если повтор снова завершился ошибкой — выполняется следующий.
  4. После исчерпания лимита retries ошибка возвращается в endpoint.

Retry и fetchBaseQuery

Наиболее распространённая комбинация:

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
});

Теперь последовательность будет такой:

  • первая попытка;
  • retry №1;
  • retry №2;
  • retry №3;
  • окончательная ошибка.

Поведение delay между повторами

RTK Query использует exponential backoff — экспоненциальное увеличение задержки.

Типичная схема:

Попытка Пример задержки
1 600ms
2 1200ms
3 2400ms
4 4800ms

Такой подход предотвращает мгновенную перегрузку сервера.


Exponential backoff

Экспоненциальная задержка считается стандартом в сетевых системах.

Простейшая формула:

y = baseDelay ^n

Где:

  • baseDelay — начальная задержка;
  • n — номер повторной попытки.

RTK Query автоматически реализует подобную стратегию.


Кастомная retry-конфигурация

Полная конфигурация:

const baseQueryWithRetry = retry(baseQuery, {
    maxRetries: 5
});

Хотя встроенная конфигурация минималистична, её обычно достаточно для большинства приложений.


Retry только для временных ошибок

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

Например:

Код Нужно ли повторять
500 Да
502 Да
503 Да
504 Да
401 Нет
403 Нет
404 Обычно нет
422 Нет

Ошибки авторизации или валидации не исчезают автоматически, поэтому retry для них бесполезен.


Условный retry через custom baseQuery

Для более тонкого контроля создаётся собственный 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() немедленно прекращает цепочку повторов.

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

  • ошибок авторизации;
  • бизнес-ошибок;
  • некорректных параметров;
  • отсутствующих ресурсов.

Пример:

retry.fail(result.error);

После вызова:

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

Retry и авторизация

Одна из самых распространённых задач — повтор запроса после обновления access token.

Сценарий:

  1. Сервер возвращает 401.
  2. Выполняется refresh token запрос.
  3. Access token обновляется.
  4. Исходный запрос повторяется.

Пример:

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
    }
);

Retry после refresh token

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

Основной запрос
    ↓
401 Unauthorized
    ↓
Refresh token
    ↓
Повтор исходного запроса
    ↓
При неудаче — retry

Если refresh token тоже завершится ошибкой, можно вызвать:

retry.fail(refreshResult.error);

Retry и сетевые ошибки

RTK Query корректно обрабатывает:

  • отсутствие соединения;
  • DNS-ошибки;
  • timeout;
  • aborted requests;
  • fetch failures.

Пример ошибки:

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

Retry особенно полезен именно для подобных временных проблем.


Retry и timeout

Часто 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.

Разница:

Retry Polling
Повторяет ошибку Выполняет периодический запрос
Реагирует на failure Работает постоянно
Используется при временных сбоях Используется для обновления данных

Polling:

useGetPostsQuery(undefined, {
    pollingInterval: 10000
});

Retry:

retry(baseQuery)

Эти механизмы часто используются совместно.


Retry и cache

Retry не влияет на cache напрямую.

Последовательность:

  1. Запрос отправляется.
  2. Retry пытается получить успешный ответ.
  3. Только успешный ответ попадает в cache.

Ошибки не кешируются как данные.


Retry и invalidateTags

Retry работает до завершения mutation.

Пример:

updatePost: builder.mutation({
    query: (body) => ({
        url: `/posts/${body.id}`,
        method: 'PUT',
        body
    }),
    invalidatesTags: ['Posts']
})

Если mutation временно завершится ошибкой:

  1. retry попытается выполнить запрос снова;
  2. успешный ответ завершит mutation;
  3. только после этого сработает invalidation.

Retry и optimistic updates

При 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:

  • optimistic upd ate остаётся;
  • rollback не выполняется.

Если retries исчерпаны:

  • queryFulfilled выбросит ошибку;
  • произойдёт undo.

Retry и abort

Abort полностью отменяет retry-цепочку.

Пример:

const promise = dispatch(
    api.endpoints.getPosts.initiate()
);

promise.abort();

После abort:

  • текущий запрос прерывается;
  • будущие retry не запускаются;
  • endpoint получает aborted state.

Retry и React Strict Mode

В React Strict Mode некоторые эффекты вызываются дважды в development-режиме.

Это может привести к:

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

Однако production-сборка работает корректно.


Retry и SSR

При SSR повторные запросы требуют осторожности.

Причины:

  • увеличение времени рендера;
  • блокировка ответа сервера;
  • перегрузка backend;
  • ухудшение TTFB.

На сервере retry часто ограничивают:

const baseQuery = retry(baseQueryRaw, {
    maxRetries: 1
});

Retry и WebSocket

Retry относится исключительно к HTTP-запросам через baseQuery.

WebSocket reconnect реализуется отдельно.

Retry не управляет:

  • socket reconnect;
  • SSE reconnect;
  • websocket lifecycle.

Retry в production-приложениях

На практике обычно используются такие настройки:

Тип запроса Retry
GET 3–5
POST 1–2
PUT 1–2
DELETE 0–1
Авторизация Осторожно
Upload файлов Минимально

Опасности чрезмерного retry

Слишком агрессивные повторы способны вызвать:

  • лавинообразную нагрузку;
  • дублирование операций;
  • race conditions;
  • проблемы с платежами;
  • повторное создание сущностей.

Особенно опасен retry для:

  • финансовых операций;
  • оформления заказов;
  • отправки email;
  • webhook-запросов;
  • интеграций с внешними сервисами.

Идемпотентность запросов

Retry безопаснее для идемпотентных операций.

Идемпотентность означает:

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

Примеры:

Метод Обычно идемпотентен
GET Да
PUT Да
DELETE Да
POST Часто нет

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

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

Проблемный сценарий:

POST /orders

Сервер:

  1. создал заказ;
  2. отправил ответ;
  3. соединение оборвалось.

Клиент считает запрос неудачным и повторяет POST.

Результат:

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

Защита от дублирования

Для безопасного retry POST-запросов применяют:

  • idempotency keys;
  • transaction IDs;
  • request UUID;
  • server deduplication.

Пример:

headers.se t(
    'Idempotency-Key',
    crypto.randomUUID()
);

Retry и микросервисная архитектура

В микросервисах retry особенно важен из-за:

  • сетевой нестабильности;
  • service discovery;
  • балансировщиков;
  • временной деградации сервисов;
  • circuit breaker механизмов.

RTK Query помогает скрывать кратковременные сбои от UI.


Retry и UX

Автоматические повторы улучшают пользовательский опыт:

  • уменьшается количество ложных ошибок;
  • интерфейс становится стабильнее;
  • исчезают кратковременные failure states;
  • уменьшается количество ручных обновлений страницы.

Однако слишком долгий retry способен ухудшать UX из-за длительного ожидания.


Retry и loading states

Во время retry endpoint остаётся в loading-состоянии.

Например:

const {
    data,
    error,
    isLoading,
    isFetching
} = useGetPostsQuery();

Пока идут retries:

  • isLoading = true
  • isFetching = true
  • error = undefined

Ошибка появится только после окончательного провала.


Retry и DevTools

В Redux DevTools можно наблюдать:

  • повторные dispatch;
  • pending actions;
  • rejected actions;
  • lifecycle query.

Это полезно при отладке retry-поведения.


Полноценная production-конфигурация

Пример комплексной реализации:

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']
        })
    })
});