Пошук уроків, статей та іншого контенту
Реалізуєте повторні спроби, експоненційну затримку та захист від зайвих паралельних запитів.
Мережевий запит може завершитися помилкою з причин, які не означають, що операція неможлива:
тимчасово недоступний сервер;
короткочасний збій мережі;
перевищення ліміту запитів;
тайм-аут;
тимчасова помилка проксі або балансувальника;
відповідь 503 Service Unavailable.
У таких випадках повторна спроба може бути корисною. Але без обмежень повтори створюють нові проблеми:
сервер отримує лавину однакових запитів;
користувач чекає надто довго;
кілька компонентів одночасно завантажують ті самі дані;
операція з побічним ефектом може виконатися кілька разів;
багато клієнтів повторюють запит одночасно після збою.
Надійна стратегія повторення зазвичай містить:
чіткий ліміт кількості спроб;
повторення лише тимчасових помилок;
експоненційну затримку;
випадковий jitter;
підтримку Retry-After;
скасування через AbortController
дедуплікацію однакових паралельних запитів.
Не кожен код відповіді означає, що запит потрібно повторити.
Зазвичай повторення можливе для:
408 Request Timeout;
425 Too Early;
429 Too Many Requests;
500 Internal Server Error;
502 Bad Gateway;
503 Service Unavailable;
504 Gateway Timeout.
Не варто автоматично повторювати:
400 Bad Request;
401 Unauthorized;
403 Forbidden;
404 Not Found;
405 Method Not Allowed;
409 Conflict, якщо конфлікт потребує зміни даних;
422 Unprocessable Content.
Наприклад, повторення 404 не зробить ресурс існуючим, а повторення 401 без оновлення токена не допоможе.
Важлива особливість fetch: він не відхиляє Promise через HTTP-коди 4xx або 5xx. Запит із відповіддю 503 формально завершується успішно на рівні Promise. Тому статус потрібно перевіряти вручну.
const response = await fetch("/api/profile");
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}fetch відхиляє Promise, коли запит не вдалося виконати на мережевому рівні. Наприклад:
немає з'єднання;
DNS не зміг знайти сервер;
з'єднання було перервано;
виникла помилка CORS;
запит було скасовано.
Не всі такі помилки тимчасові. Наприклад, помилка CORS зазвичай не зникне після повторення. Тому повторення мережевих помилок потрібно обмежувати, а остаточну обробку виконувати на рівні інтерфейсу.
Повторення безпечніше для ідемпотентних операцій.
Ідемпотентна операція дає той самий ефект при виконанні один або кілька разів. Типові приклади:
GET — отримати дані;
HEAD — отримати заголовки;
PUT — встановити ресурс у певний стан;
DELETE — видалити ресурс.
Однак ідемпотентність залежить не лише від HTTP-методу, а й від реалізації сервера. Наприклад, сервер може некоректно обробляти повторний DELETE.
POST часто створює новий ресурс. Якщо повторити його після тайм-ауту, перший запит міг успішно виконатися, але відповідь не дійшла до браузера. Другий запит може створити дубль.
Для безпечного повторення POST сервер може підтримувати ключ ідемпотентності:
await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID()
},
body: JSON.stringify({
amount: 1000,
currency: "UAH"
})
});Сервер має зберігати цей ключ і повертати той самий результат для повторних запитів із таким самим ключем.
Якщо повторювати запит одразу, це мало відрізняється від постійного навантаження на несправний сервер. Краще збільшувати затримку між спробами.
Одна з формул:
затримка = min(максимальна_затримка, базова_затримка × 2^(номер_повтору))Наприклад, для базової затримки 250 мс:
після першої невдалої спроби — 250 мс;
після другої — 500 мс;
після третьої — 1000 мс;
після четвертої — 2000 мс.
Максимальну затримку потрібно обмежувати. Інакше одна операція може чекати кілька хвилин.
Якщо тисячі клієнтів отримали помилку одночасно, без випадкової складової вони повторять запити в однакові моменти. Це створить нові піки навантаження.
Для full jitter затримка обирається випадково в діапазоні від нуля до розрахованого максимуму:
const maximumDelay = 2000;
const delay = Math.random() * maximumDelay;У результаті клієнти розподіляють повторні запити в часі.
Нижче наведено реалізацію, яка:
повторює тимчасові HTTP-помилки;
повторює мережеві помилки та тайм-аути;
використовує експоненційну затримку з full jitter;
враховує заголовок Retry-After;
підтримує AbortSignal;
за замовчуванням не повторює небезпечні методи;
має обмеження кількості спроб і часу очікування.
class HttpError extends Error {
constructor(response) {
super(`HTTP ${response.status}`);
this.name = "HttpError";
this.status = response.status;
this.response = response;
}
}
const RETRYABLE_STATUS_CODES = new Set([
408,
425,
429,
500,
502,
503,
504
]);
const IDEMPOTENT_METHODS = new Set([
"GET",
"HEAD",
"OPTIONS",
"PUT",
"DELETE"
]);
function isRetryableStatus(status) {
return RETRYABLE_STATUS_CODES.has(status);
}
function isRetryableMethod(method) {
return IDEMPOTENT_METHODS.has(method.toUpperCase());
}
function parseRetryAfter(value) {
if (!value) {
return null;
}
// Retry-After може містити кількість секунд.
if (/^\d+$/.test(value.trim())) {
return Number(value.trim()) * 1000;
}
// Або HTTP-дату.
const date = Date.parse(value);
if (Number.isNaN(date)) {
return null;
}
return Math.max(0, date - Date.now());
}
function wait(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new DOMException("Операцію скасовано", "AbortError"));
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort);
resolve();
}, milliseconds);
function abort() {
clearTimeout(timer);
reject(signal.reason ?? new DOMException("Операцію скасовано", "AbortError"));
}
signal?.addEventListener("abort", abort, { once: true });
});
}
function createAttemptController(externalSignal, timeoutMilliseconds) {
const controller = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort(new DOMException("Час очікування вичерпано", "TimeoutError"));
}, timeoutMilliseconds);
function abortFromOutside() {
controller.abort(
externalSignal.reason ??
new DOMException("Операцію скасовано", "AbortError")
);
}
if (externalSignal) {
if (externalSignal.aborted) {
abortFromOutside();
} else {
externalSignal.addEventListener("abort", abortFromOutside, { once: true });
}
}
return {
signal: controller.signal,
wasTimedOut() {
return timedOut;
},
cleanup() {
clearTimeout(timeout);
externalSignal?.removeEventListener("abort", abortFromOutside);
}
};
}
async function requestWithRetry(url, options = {}) {
const {
method = "GET",
signal,
maxAttempts = 4,
baseDelay = 250,
maxDelay = 5000,
timeout = 10000,
...fetchOptions
} = options;
const normalizedMethod = method.toUpperCase();
if (!isRetryableMethod(normalizedMethod) && maxAttempts > 1) {
throw new Error(
`Метод ${normalizedMethod} не можна безпечно повторювати без ідемпотентності`
);
}
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const attemptController = createAttemptController(signal, timeout);
try {
const response = await fetch(url, {
...fetchOptions,
method: normalizedMethod,
signal: attemptController.signal
});
if (response.ok) {
return response;
}
const shouldRetry =
isRetryableStatus(response.status) &&
attempt < maxAttempts;
if (!shouldRetry) {
throw new HttpError(response);
}
const retryAfter = parseRetryAfter(
response.headers.get("Retry-After")
);
// Після повторної спроби відповідь більше не потрібна.
await response.body?.cancel();
if (retryAfter !== null) {
// Retry-After має пріоритет над локальним розрахунком.
await wait(Math.min(retryAfter, maxDelay), signal);
} else {
const exponentialDelay = Math.min(
maxDelay,
baseDelay * 2 ** (attempt - 1)
);
// Випадкова затримка зменшує одночасні повтори клієнтів.
const jitteredDelay = Math.random() * exponentialDelay;
await wait(jitteredDelay, signal);
}
} catch (error) {
lastError = error;
if (error instanceof HttpError) {
throw error;
}
if (signal?.aborted) {
throw signal.reason ?? error;
}
const isTimeout = attemptController.wasTimedOut();
const isNetworkError = error instanceof TypeError;
const shouldRetry =
(isTimeout || isNetworkError) &&
attempt < maxAttempts;
if (!shouldRetry) {
throw error;
}
const exponentialDelay = Math.min(
maxDelay,
baseDelay * 2 ** (attempt - 1)
);
const jitteredDelay = Math.random() * exponentialDelay;
await wait(jitteredDelay, signal);
} finally {
attemptController.cleanup();
}
}
throw lastError;
}Приклад використання:
async function loadPost() {
const controller = new AbortController();
// Для демонстрації скасування можна викликати:
// controller.abort();
try {
const response = await requestWithRetry(
"https://jsonplaceholder.typicode.com/posts/1",
{
method: "GET",
signal: controller.signal,
maxAttempts: 4,
baseDelay: 300,
maxDelay: 3000,
timeout: 5000
}
);
const post = await response.json();
console.log(post);
} catch (error) {
if (error.name === "AbortError") {
console.log("Запит скасовано");
return;
}
if (error.name === "TimeoutError") {
console.log("Сервер не відповів вчасно");
return;
}
if (error instanceof HttpError) {
console.log(`Сервер повернув ${error.status}`);
return;
}
console.error("Не вдалося виконати запит", error);
}
}
loadPost();Retry-AfterСервер може повідомити клієнту, коли варто повторити запит:
HTTP/1.1 429 Too Many Requests
Retry-After: 3У цьому випадку значення 3 означає три секунди.
Також допустимий формат HTTP-дати:
Retry-After: Wed, 29 Jul 2026 12:00:00 GMTКлієнт має:
перевірити, чи заголовок має коректний формат;
не чекати довше за власний максимальний ліміт;
поважати сигнал скасування під час очікування;
не робити необмежені повтори.
Якщо Retry-After відсутній, можна використати локальну експоненційну стратегію.
Повторення вирішує проблему послідовних невдалих запитів, але не захищає від паралельних.
Наприклад, кілька компонентів можуть одночасно викликати:
loadUser();
loadUser();
loadUser();Якщо кожен виклик запускає власний fetch, браузер відправить три однакові запити.
Для безпечних операцій читання можна зберігати Promise активного запиту в Map. Усі виклики з однаковим ключем отримають той самий Promise.
const inFlightRequests = new Map();
function getJson(url, options = {}) {
const requestUrl = new URL(url, window.location.href).href;
// Ключ має враховувати всі параметри, що впливають на результат.
const key = JSON.stringify({
url: requestUrl,
method: "GET",
headers: options.headers ?? {}
});
const existingRequest = inFlightRequests.get(key);
if (existingRequest) {
return existingRequest;
}
const request = requestWithRetry(requestUrl, {
...options,
method: "GET"
})
.then((response) => response.json())
.finally(() => {
// Видаляємо лише цей Promise, не зачіпаючи новий запит
// із таким самим ключем, який міг з'явитися пізніше.
if (inFlightRequests.get(key) === request) {
inFlightRequests.delete(key);
}
});
inFlightRequests.set(key, request);
return request;
}
async function showUser() {
const user = await getJson(
"https://jsonplaceholder.typicode.com/users/1"
);
console.log(user);
}
Promise.all([
showUser(),
showUser(),
showUser()
]);У цьому прикладі три виклики showUser використовують один мережевий запит, якщо вони виконуються до його завершення.
Дедуплікація підходить переважно для:
GET;
HEAD;
інших операцій без побічних ефектів.
Не можна об'єднувати запити лише за URL, якщо на результат впливають:
заголовок Authorization;
мова користувача;
query-параметри;
тіло запиту;
інші заголовки;
права доступу.
Ключ має повністю описувати логічний запит. Інакше один користувач може отримати результат, призначений для іншого.
Повторення не повинні ігнорувати дії користувача. Якщо користувач залишив сторінку або змінив пошуковий запит, стару операцію краще скасувати.
let searchController = null;
async function search(query) {
searchController?.abort();
searchController = new AbortController();
try {
const result = await getJson(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: searchController.signal,
timeout: 4000,
maxAttempts: 3
}
);
console.log(result);
} catch (error) {
if (error.name === "AbortError") {
// Старий пошук більше не потрібен.
return;
}
console.error(error);
}
}Скасування важливе з двох причин:
воно звільняє ресурси браузера;
воно не дозволяє застарілій відповіді перезаписати нові дані.
Тайм-аут для окремої спроби та загальний дедлайн — різні речі. Наприклад:
кожна спроба може тривати не більше п'яти секунд;
уся операція разом із повторами може тривати не більше десяти секунд.
Без загального дедлайну кілька повільних спроб можуть значно збільшити час очікування.
Повторення мають бути непомітними для користувача лише тоді, коли вони короткі та очікувані. Інтерфейс повинен розрізняти стани:
перша спроба виконується;
виконується повторна спроба;
операцію скасовано;
остаточно отримано помилку;
відповідь успішна.
Для довгих операцій можна показати повідомлення:
Сервер тимчасово недоступний. Повторюємо запит…
Не варто показувати користувачу технічні деталі на кшталт TypeError: Failed to fetch, але їх потрібно записувати в журнали для діагностики.
401 Unauthorized зазвичай не належить до звичайних повторюваних помилок. Окремий контрольований сценарій може бути таким:
виконати запит із поточним токеном;
отримати 401;
один раз оновити токен;
повторити початковий запит;
якщо знову отримано 401, завершити сесію.
Важливо обмежити оновлення токена однією спробою. Інакше помилка автентифікації може спричинити нескінченний цикл:
запит → 401 → оновлення токена → запит → 401 → оновлення токена → ...Не слід повторювати запит, якщо:
користувач уже скасував операцію;
помилка викликана некоректними вхідними даними;
ресурс не існує;
користувач не має доступу;
операція не є ідемпотентною;
сервер повернув бізнес-помилку;
перевищено загальний дедлайн;
повторення не змінить причину помилки.
Для POST, який створює замовлення або проводить оплату, краще використовувати серверну ідемпотентність, а не просто збільшувати кількість повторів.
while (true) {
try {
return await fetch(url);
} catch {
// Повторення без кінця — помилка.
}
}Завжди встановлюйте maxAttempts і максимальну затримку.
catchHTTP-відповідь 500 не потрапить у catch автоматично. Потрібно перевіряти response.ok або response.status.
Однакова затримка для всіх клієнтів може створити синхронні хвилі повторних запитів. Використовуйте експоненційну затримку та jitter.
POST без захистуЯкщо відповідь загубилася після успішного створення ресурсу, повторний POST може створити дубль. Використовуйте ключ ідемпотентності або не повторюйте операцію автоматично.
Retry-AfterКод 429 часто означає, що клієнт надсилає надто багато запитів. Ігнорування Retry-After погіршує ситуацію.
Старі запити можуть завершитися пізніше за нові та перезаписати актуальний стан інтерфейсу.
Однаковий URL не гарантує однаковий логічний запит. Враховуйте метод, заголовки, query-параметри та тіло.
ResponseТіло Response можна прочитати лише один раз. Якщо потрібно використовувати його кілька разів, заздалегідь збережіть дані або використайте response.clone() до читання тіла.
Кожна внутрішня спроба не обов'язково є окремою помилкою для користувача. Логуйте фінальний збій і корисний контекст: URL, метод, кількість спроб, статус та тривалість.
Повторюйте лише тимчасові помилки, а не всі помилки підряд.
Перевіряйте HTTP-статус вручну, оскільки fetch не відхиляє Promise для 4xx і 5xx.
Обмежуйте кількість спроб і максимальну затримку.
Використовуйте експоненційну затримку з випадковим jitter.
Поважайте заголовок Retry-After.
Не повторюйте небезпечні операції без ідемпотентності.
Для POST із побічним ефектом використовуйте ключ ідемпотентності.
Скасовуйте застарілі запити через AbortController.
Дедуплікуйте однакові паралельні запити читання.
Ключ дедуплікації має враховувати всі параметри, що впливають на результат.