Тема
Конкурентность запросов и 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.pendingRequestAttemptCountactiveRequestCount сохраняет прежний смысл: это число логических direct executions в RequestExecutor, включая те, которые могут ждать слот или retry.