Skip to content

Конкурентность запросов и retry jitter

QueryRuntime может ограничить количество HTTP-попыток, которые одновременно выполняются в одном экземпляре приложения. Одна очередь совместно обслуживает RequestStore, FetchStore, QueryStore, InfiniteQueryStore, MutationStore и HTTP-попытки OfflineMutationQueue, созданные поверх этого runtime.

Базовый лимит

ts
import { createQueryRuntime } from "@dubium/query-layer/runtime"

const runtime = createQueryRuntime({
    identity: { applicationId: "customer-portal" },
    maxConcurrentRequests: 5,
})

Если одновременно запущены 20 разных HTTP-операций, первые пять займут слоты, а остальные 15 будут ждать в FIFO-очереди. Success и окончательная ошибка освобождают слот. При отмене вызывающий Promise отклоняется сразу, но слот освобождается только после фактического завершения transport attempt.

Если maxConcurrentRequests и requestConcurrency не заданы, количество одновременных попыток не ограничивается. Это сохраняет поведение существующих приложений.

Локальный лимит

Очередь существует только внутри текущего QueryRuntime. Это не глобальный rate limit backend: другой браузер, вкладка или отдельный runtime имеет собственную очередь.

Группы запросов

Именованные группы получают независимые лимиты:

ts
const runtime = createQueryRuntime({
    identity: { applicationId: "customer-portal" },
    requestConcurrency: {
        default: 5,
        groups: {
            auth: 2,
            search: 5,
            analytics: 1,
        },
    },
})

Запрос без requestGroup, а также запрос с ненастроенным именем группы, использует общий пул default. Настроенные группы независимы: например, две активные попытки auth не занимают пять слотов search.

Если одновременно заданы maxConcurrentRequests и requestConcurrency.default, явный requestConcurrency.default имеет приоритет.

Группа задаётся в конфигурации Store:

ts
const login = scope.request.createStore({
    api: loginApi,
    options: {
        requestGroup: "auth",
        retry: false,
    },
})

const search = scope.query.createStore({
    queryKey: () => ["search", filters],
    queryFn: searchApi,
    requestGroup: "search",
})

У RequestStore, FetchStore и MutationStore поле находится в options. У QueryStore и InfiniteQueryStore это поле верхнего уровня конфигурации.

Чем отличаются настройки

НастройкаОбласть действия
retryКоличество повторных попыток одного конкретного запуска запроса. retry: 2 означает первоначальную попытку и не более двух повторов.
maxConcurrentRequestsМаксимальное количество HTTP-попыток, одновременно выполняемых в общем пуле текущего runtime.
requestConcurrencyЛимит общего пула и независимые лимиты именованных групп.
retryDelayЗадержка между попытками одного запроса. Во время задержки слот очереди свободен.
встроенный jitterСлучайный разброс встроенной exponential-backoff задержки, рассинхронизирующий разные клиенты.

Счётчик failureCount принадлежит конкретному вызову. Два параллельных fetch(...) не разделяют число оставшихся retry.

Retry снова проходит через очередь

Очередь ограничивает каждую HTTP-попытку, а не весь retry lifecycle:

mermaid
flowchart TD
    Store["Store"] --> Executor["Общий execution layer"]
    Executor --> Queue["Concurrency FIFO"]
    Queue --> Http["HTTP attempt"]
    Http -->|success| Result["Result"]
    Http -->|error и retry разрешён| Delay["Backoff + full jitter"]
    Delay --> Queue

После ошибки слот освобождается до retryDelay. Когда задержка заканчивается, retry становится в хвост той же групповой FIFO-очереди. Поэтому ожидающий backoff не блокирует другие запросы и не обходит лимит.

Встроенный exponential backoff и jitter

Без пользовательского retryDelay базовая задержка равна:

ts
const baseDelay = Math.min(
    1_000 * 2 ** Math.max(failureCount - 1, 0),
    30_000,
)

Получаются границы 1s, 2s, 4s, 8s, 16s, затем максимум 30s. Встроенная стратегия применяет full jitter:

ts
const actualDelay = Math.random() * baseDelay

Итог находится между 0 и baseDelay. Если передано число, например retryDelay: 5_000, каждый retry ждёт ровно пять секунд. Значение функции retryDelay(failureCount, error) также используется без дополнительного jitter.

Для детерминированных интеграционных тестов runtime принимает retryRandom: () => number. В production это поле обычно не задают.

Отмена и dispose

  • отмена pending-попытки удаляет её из FIFO до вызова API-функции;
  • отмена активной попытки прерывает переданный AbortSignal; если transport игнорирует сигнал, физический слот остаётся занятым до его завершения;
  • отмена во время retryDelay не позволяет retry снова попасть в очередь;
  • store.dispose() отменяет принадлежащее Store ожидание в соответствии с его существующей ownership-семантикой;
  • scope.dispose() и runtime.dispose() отменяют owned executions и больше не запускают ожидающие попытки.

Для shared Query consumer-level dispose() по-прежнему не отменяет запрос, который используют другие observers. Административная отмена shared Query остаётся ответственностью владельца runtime/client.

Диагностика

runtime.getDiagnostics() показывает состояние очереди без URL, payload и credentials:

ts
const diagnostics = runtime.getDiagnostics()

diagnostics.activeRequestAttemptCount
diagnostics.pendingRequestAttemptCount

activeRequestCount сохраняет прежний смысл: это число логических direct executions в RequestExecutor, включая те, которые могут ждать слот или retry.