Пошук уроків, статей та іншого контенту
Реалізуєте обмеження кількості одночасних задач, повторні спроби та затримки для надійної роботи з асинхронними процесами.
Асинхронність не означає необмежену паралельність. Якщо запустити сотні або тисячі операцій через Promise.all, можна:
перевищити ліміти API;
вичерпати з’єднання з базою даних;
створити надмірне навантаження на CPU або пам’ять;
отримати помилки 429 Too Many Requests;
запустити так багато повторних спроб, що система погіршить власний стан.
const results = await Promise.all(
urls.map((url) => fetch(url))
);У цьому прикладі всі запити запускаються практично одночасно. Promise.all очікує завершення промісів, але не обмежує їхню кількість.
Керування конкурентністю складається з двох окремих задач:
визначити максимальну кількість одночасно активних операцій;
визначити, які помилки можна повторити та з якою затримкою.
Конкурентність — це кількість задач, які виконуються одночасно.
Наприклад, якщо concurrency дорівнює 3, у будь-який момент часу можуть виконуватися не більше трьох операцій. Після завершення однієї задачі запускається наступна.
Важливо відрізняти:
кількість логічних задач — наприклад, обробка 100 файлів;
кількість активних спроб — фактичні HTTP-запити або операції читання;
кількість повторних спроб — додаткові виконання після тимчасових помилок.
Зазвичай слот конкурентності утримується всією логічною задачею, включно з її повторними спробами. Це запобігає ситуації, коли одна невдала задача створює багато паралельних повторних запитів.
mapLimitНижче наведена функція, яка:
обробляє елементи з обмеженою конкурентністю;
зберігає порядок результатів;
зупиняє запуск нових задач після першої помилки;
підтримує AbortSignal;
передає до обробника індекс елемента та сигнал скасування.
function createAbortError(signal) {
return signal?.reason instanceof Error
? signal.reason
: new Error("Операцію скасовано");
}
async function mapLimit(items, worker, options = {}) {
const {
concurrency = 4,
signal
} = options;
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError("concurrency має бути додатним цілим числом");
}
const results = new Array(items.length);
const workerCount = Math.min(concurrency, items.length);
let nextIndex = 0;
let stopped = false;
let firstError = null;
async function consume() {
while (!stopped) {
if (signal?.aborted) {
stopped = true;
throw createAbortError(signal);
}
const index = nextIndex++;
if (index >= items.length) {
return;
}
try {
results[index] = await worker(items[index], index, signal);
} catch (error) {
stopped = true;
firstError = error;
return;
}
}
}
await Promise.all(
Array.from({ length: workerCount }, () => consume())
);
if (firstError) {
throw firstError;
}
if (signal?.aborted) {
throw createAbortError(signal);
}
return results;
}Змінна nextIndex безпечно збільшується, оскільки JavaScript виконує синхронні ділянки коду послідовно. Між операціями nextIndex++ і перевіркою індексу немає await, тому два воркери не отримають один і той самий індекс.
Порядок результатів зберігається завдяки рядку:
results[index] = await worker(items[index], index, signal);Задачі можуть завершуватися в довільному порядку, але кожен результат записується на позицію свого елемента.
У серверному JavaScript не можна використовувати синхронне блокування потоку для очікування:
// Поганий підхід: блокує потік виконання
while (Date.now() < deadline) {
// очікування
}Замість цього використовується setTimeout, який не блокує event loop:
function sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError(signal));
return;
}
let timer;
const onAbort = () => {
clearTimeout(timer);
reject(createAbortError(signal));
};
timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
signal?.addEventListener("abort", onAbort, { once: true });
});
}Підтримка AbortSignal важлива: якщо операцію скасовано під час затримки, не потрібно чекати, поки таймер завершиться природним шляхом.
Повторювати потрібно не кожну помилку.
Зазвичай повторна спроба виправдана для:
тимчасової мережевої помилки;
тайм-ауту;
відповіді 429;
помилок сервера 500, 502, 503, 504;
тимчасової недоступності залежності.
Зазвичай не слід повторювати:
помилки валідації;
400 Bad Request;
401 Unauthorized, якщо токен не оновлюється;
403 Forbidden;
помилки бізнес-логіки;
операції, які не є ідемпотентними.
Ідемпотентна операція дає той самий ефект при повторному виконанні. Наприклад, GET зазвичай ідемпотентний. А повторне виконання POST, який створює платіж або замовлення, може створити дубль.
Фіксована затримка:
500 мс, 500 мс, 500 мсможе спричинити синхронне повторне навантаження: багато клієнтів повторять запити в один і той самий момент.
Експоненційна затримка збільшує паузу після кожної невдачі:
250 мс, 500 мс, 1000 мс, 2000 мсТипова формула:
delay = min(maxDelay, baseDelay × 2^(attempt - 1))До неї додають jitter — випадкове відхилення. Це розподіляє повторні запити в часі.
retryasync function retry(operation, options = {}) {
const {
retries = 3,
baseDelay = 250,
maxDelay = 5_000,
jitter = 0.2,
shouldRetry = () => true,
signal
} = options;
if (!Number.isInteger(retries) || retries < 0) {
throw new RangeError("retries має бути невід’ємним цілим числом");
}
if (baseDelay < 0 || maxDelay < 0) {
throw new RangeError("Затримка не може бути від’ємною");
}
for (let attempt = 1; ; attempt += 1) {
if (signal?.aborted) {
throw createAbortError(signal);
}
try {
return await operation(attempt);
} catch (error) {
const attemptsExhausted = attempt > retries;
const canRetry = shouldRetry(error, attempt);
if (attemptsExhausted || !canRetry) {
throw error;
}
const exponentialDelay = Math.min(
maxDelay,
baseDelay * (2 ** (attempt - 1))
);
const randomFactor = 1 - jitter + Math.random() * jitter * 2;
const delay = Math.round(exponentialDelay * randomFactor);
await sleep(delay, signal);
}
}
}Параметр retries означає кількість додаткових спроб. Отже:
retries: 0 — лише одна спроба;
retries: 3 — максимум чотири виконання загалом.
Операція отримує номер поточної спроби. Це може бути корисно для журналювання або для передачі діагностичної інформації.
Приклад нижче можна запустити в Node.js. Він імітує нестабільний зовнішній сервіс:
одночасно обробляються не більше трьох записів;
деякі записи тимчасово завершуються помилками;
повторні спроби використовують експоненційну затримку;
результати повертаються в початковому порядку.
function createAbortError(signal) {
return signal?.reason instanceof Error
? signal.reason
: new Error("Операцію скасовано");
}
function sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError(signal));
return;
}
let timer;
const onAbort = () => {
clearTimeout(timer);
reject(createAbortError(signal));
};
timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
async function retry(operation, options = {}) {
const {
retries = 3,
baseDelay = 100,
maxDelay = 2_000,
jitter = 0.2,
shouldRetry = () => true,
signal
} = options;
for (let attempt = 1; ; attempt += 1) {
if (signal?.aborted) {
throw createAbortError(signal);
}
try {
return await operation(attempt);
} catch (error) {
if (attempt > retries || !shouldRetry(error, attempt)) {
throw error;
}
const exponentialDelay = Math.min(
maxDelay,
baseDelay * (2 ** (attempt - 1))
);
const randomFactor = 1 - jitter + Math.random() * jitter * 2;
const delay = Math.round(exponentialDelay * randomFactor);
console.log(
`Повторна спроба ${attempt + 1} через ${delay} мс`
);
await sleep(delay, signal);
}
}
}
async function mapLimit(items, worker, options = {}) {
const {
concurrency = 4,
signal
} = options;
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError("concurrency має бути додатним цілим числом");
}
const results = new Array(items.length);
const workerCount = Math.min(concurrency, items.length);
let nextIndex = 0;
let stopped = false;
let firstError = null;
async function consume() {
while (!stopped) {
if (signal?.aborted) {
stopped = true;
throw createAbortError(signal);
}
const index = nextIndex++;
if (index >= items.length) {
return;
}
try {
results[index] = await worker(items[index], index, signal);
} catch (error) {
stopped = true;
firstError = error;
return;
}
}
}
await Promise.all(
Array.from({ length: workerCount }, () => consume())
);
if (firstError) {
throw firstError;
}
if (signal?.aborted) {
throw createAbortError(signal);
}
return results;
}
async function requestRecord(id, attempt, signal) {
await sleep(100 + Math.random() * 150, signal);
// Імітація тимчасових помилок зовнішнього сервісу.
if (id % 3 === 0 && attempt < 3) {
const error = new Error("Сервіс тимчасово недоступний");
error.status = 503;
throw error;
}
if (id % 3 === 1 && attempt < 2) {
const error = new Error("Перевищено ліміт запитів");
error.status = 429;
throw error;
}
return {
id,
value: `record-${id}`,
attempt
};
}
async function main() {
const controller = new AbortController();
const ids = [101, 102, 103, 104, 105, 106, 107, 108];
try {
const records = await mapLimit(
ids,
(id, index, signal) =>
retry(
(attempt) => requestRecord(id, attempt, signal),
{
retries: 3,
baseDelay: 100,
maxDelay: 1_000,
shouldRetry: (error) =>
error.status === 429 ||
error.status >= 500,
signal
}
).then((record) => {
console.log(
`Готово: id=${record.id}, індекс=${index}, спроба=${record.attempt}`
);
return record;
}),
{
concurrency: 3,
signal: controller.signal
}
);
console.log("Результати:", records);
} catch (error) {
console.error("Обробку завершено з помилкою:", error.message);
}
}
main();У цьому коді кожна задача займає один слот mapLimit доти, доки не завершиться остаточно. Якщо для запису потрібні три HTTP-спроби, інші задачі все одно не перевищать ліміт у три активні логічні задачі.
429Відповідь 429 часто може містити інформацію про час очікування, наприклад заголовок Retry-After. Якщо сервер явно вказує затримку, її варто використовувати замість локальної експоненційної формули.
Концептуально логіка має виглядати так:
const retryAfter = response.headers.get("retry-after");Значення заголовка може бути кількістю секунд або HTTP-датою, тому його потрібно коректно розібрати та обмежити максимально допустимою затримкою.
Не варто безконтрольно довіряти значенню від сервера. Навіть якщо сервер вказав дуже велику паузу, клієнту можуть бути потрібні:
максимальний час очікування;
дедлайн усієї операції;
скасування через AbortSignal.
Ліміт кількості спроб не обмежує загальний час виконання. Наприклад, чотири спроби з великими затримками можуть тривати кілька хвилин.
Для обмеження часу можна створити AbortController із таймером:
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort(new Error("Перевищено загальний дедлайн"));
}, 10_000);
try {
const result = await mapLimit(
items,
worker,
{
concurrency: 4,
signal: controller.signal
}
);
console.log(result);
} finally {
clearTimeout(timeout);
}У production-коді дедлайн має поширюватися на всі рівні:
чергу задач;
HTTP-запит;
затримку перед повторною спробою;
операцію читання або запису;
зовнішні залежності.
Якщо лише верхній рівень знає про скасування, внутрішній fetch або таймер може продовжувати роботу після того, як результат уже не потрібен.
Це різні механізми.
Обмеження конкурентності контролює кількість одночасних операцій:
не більше 5 активних запитівОбмеження швидкості контролює кількість операцій за певний проміжок часу:
не більше 100 запитів за хвилинуП’ять швидких запитів можуть завершуватися за мілісекунди, тому за хвилину їх буде набагато більше ста. Якщо зовнішній сервіс має обмеження саме за частотою, одного concurrency недостатньо.
У складніших системах комбінують:
пул із фіксованою конкурентністю;
rate limiter;
повторні спроби з backoff;
глобальний дедлайн;
облік квоти на рівні клієнта або сервісу.
Поведінка черги після помилки залежить від задачі.
Після першої невиправної помилки нові задачі не запускаються, а загальна операція завершується помилкою.
Переваги:
швидко повідомляє про проблему;
не витрачає ресурси на роботу, яка вже не має сенсу;
зручна для транзакційних або залежних операцій.
Недолік: частина вже запущених задач може ще завершуватися.
Усі незалежні задачі виконуються, а результат містить як успішні значення, так і помилки.
Для такої моделі замість Promise.all часто використовують Promise.allSettled або власний формат результатів:
const settled = await Promise.allSettled(
items.map((item) => worker(item))
);
const successful = settled.filter(
(result) => result.status === "fulfilled"
);
const failed = settled.filter(
(result) => result.status === "rejected"
);Під час використання конкурентної черги потрібно реалізувати цю політику всередині черги, а не просто обгорнути необмежений map у Promise.allSettled.
Повторна спроба не робить операцію безпечною сама по собі.
Для змінювальних операцій потрібні додаткові механізми:
ідемпотентний ключ запиту;
унікальний ідентифікатор операції;
дедуплікація на сервері;
транзакція;
перевірка поточного стану перед повторенням;
журнал виконаних операцій.
Наприклад, якщо клієнт не отримав відповідь після створення замовлення, він не знає, чи сервер обробив запит. Простий повторний POST може створити друге замовлення. Сервер повинен розпізнати той самий ідемпотентний ключ і повернути вже створений результат.
Для діагностики конкурентних задач і повторних спроб потрібно журналювати щонайменше:
ідентифікатор задачі;
номер спроби;
причину помилки;
час очікування перед повтором;
тривалість операції;
кількість активних задач;
остаточний статус;
факт скасування.
Не слід записувати в журнали токени, паролі та персональні дані.
Корисні метрики:
середня та максимальна кількість повторних спроб;
частка успішних задач із першої спроби;
кількість помилок після вичерпання спроб;
час перебування задачі в черзі;
час активного виконання;
кількість відповідей 429.
Promise.all без обмеженняawait Promise.all(items.map(processItem));Такий код може одночасно запустити тисячі операцій. Якщо кількість елементів може зростати, використовуйте чергу або пул.
try {
return await operation();
} catch {
return operation();
}Це може повторити помилку валідації або невірну автентифікацію. Політика повторення повинна аналізувати тип або статус помилки.
Однакова затримка для всіх клієнтів може спричинити повторний сплеск навантаження. Для розподілених систем використовуйте випадкове відхилення.
Збільшення retries не гарантує надійність. Воно може лише збільшити час очікування та навантаження. Додавайте максимальну затримку й дедлайн.
Повторне створення ресурсу або списання коштів може мати побічні ефекти. Переконайтеся, що операцію можна безпечно повторити.
Якщо AbortSignal перевіряється лише перед початком задачі, поточні запити можуть продовжувати виконання. Сигнал потрібно передавати до всіх операцій, які вміють його підтримувати.
Якщо результати додаються через results.push, вони будуть упорядковані за часом завершення, а не за початковим порядком вхідних даних. Для відповідності індексів записуйте результат у results[index].
Якщо черга вважає задачу завершеною після першої помилки, а повторні спроби запускаються окремо, фактична кількість активних запитів може перевищити встановлений ліміт. Повторні спроби повинні бути частиною життєвого циклу задачі або мати власний обмежувач.
Promise.all очікує проміси, але не обмежує конкурентність.
Пул задач дозволяє контролювати кількість одночасних операцій.
Експоненційний backoff з jitter зменшує повторні сплески навантаження.
Повторювати потрібно лише тимчасові або явно дозволені помилки.
retries — це кількість додаткових спроб, а не загальна кількість виконань.
Повторні спроби небезпечні для неідемпотентних операцій без механізму дедуплікації.
AbortSignal дає змогу скасувати активні задачі та очікування.
Ліміт конкурентності та ліміт швидкості вирішують різні проблеми.
Для production-систем потрібні дедлайни, метрики, журналювання та чітка політика помилок.