Пошук уроків, статей та іншого контенту
Реалізуєте повторні запити з обмеженнями, затримкою та стратегією для тимчасових помилок сервера.
Тимчасові збої мережі або сервера не завжди означають, що операцію потрібно негайно завершити помилкою. Причинами можуть бути:
короткочасна втрата мережевого з’єднання;
перевантаження сервера;
перезапуск екземпляра сервісу;
помилка шлюзу або балансувальника;
обмеження частоти запитів (429 Too Many Requests);
тимчасова недоступність залежного сервісу.
Повторний запит може виправити ситуацію, але неконтрольовані повтори здатні погіршити проблему. Якщо тисячі клієнтів одночасно повторюють запити без затримки, сервер отримує ще більше навантаження. Це називають ефектом «стада» — thundering herd.
Надійна реалізація повторів повинна мати:
обмеження кількості спроб;
обмеження загального часу;
затримку між спробами;
стратегію збільшення затримки;
випадкову складову для розподілу навантаження;
класифікацію помилок;
підтримку скасування;
врахування ідемпотентності операції.
Повторювати потрібно не всі помилки.
Зазвичай повторюють запити після таких статусів:
408 Request Timeout;
425 Too Early;
429 Too Many Requests;
500 Internal Server Error;
502 Bad Gateway;
503 Service Unavailable;
504 Gateway Timeout.
Статус 429 часто означає, що сервер повідомляє клієнту про перевищення ліміту. Сервер може додати заголовок Retry-After, який вказує, коли варто повторити запит.
У браузері та Node.js мережеві помилки часто виникають як винятки під час виклику fetch. Наприклад:
DNS-помилка;
розірване з’єднання;
неможливість підключитися до сервера;
перевищення тайм-ауту.
Однак потрібно відрізняти мережеву помилку від навмисного скасування запиту користувачем. Якщо користувач залишив сторінку або натиснув кнопку «Скасувати», повторювати такий запит не потрібно.
Статуси на кшталт 400, 401, 403, 404 зазвичай не є тимчасовими:
повторення неправильного запиту не виправить його;
відсутність авторизації не зникне сама;
ресурс із 404 зазвичай не з’явиться через кілька мілісекунд.
Такі відповіді потрібно передати коду, який викликає функцію.
Повторення безпечне лише тоді, коли повторна операція не створює небажаних наслідків.
Методи GET, HEAD та OPTIONS зазвичай є ідемпотентними. Методи PUT і DELETE концептуально також можуть бути ідемпотентними, але це залежить від реалізації API.
Із POST потрібно бути особливо обережними:
POST /paymentsЯкщо сервер прийняв платіж, але клієнт не отримав відповідь через мережеву помилку, повторний POST може створити другий платіж.
Для таких операцій використовують ключ ідемпотентності:
Idempotency-Key: 3b1f1c2e-...Сервер зберігає результат операції для цього ключа та повертає його під час повторного запиту, замість виконання операції вдруге.
Автоматично повторювати POST можна лише тоді, коли:
API явно підтримує ідемпотентність;
запит містить унікальний ключ;
клієнт явно дозволив повторення.
Замість однакової затримки між спробами використовують експоненційне збільшення:
затримка = базова_затримка × 2^(номер_спроби - 1)Наприклад, для базової затримки 300 мс:
300 мс
600 мс
1200 мс
2400 мсЗатримку потрібно обмежити зверху. Інакше після кількох спроб клієнт може чекати надто довго.
До експоненційної затримки додають випадкову складову:
затримка = випадкове число від 0 до розрахованої затримкиЦе називають full jitter. Клієнти, які отримали помилку одночасно, чекатимуть різний час і не створять нову хвилю запитів.
Обмеження лише кількості спроб недостатньо. Наприклад, п’ять спроб можуть тривати:
5 секунд через тайм-аути;
30 секунд через повільні відповіді;
кілька хвилин через великі затримки.
Тому варто мати два обмеження:
maxAttempts — максимальна кількість спроб;
maxElapsedTime — максимальний загальний час операції.
Після перевищення загального часу нову спробу розпочинати не потрібно, навіть якщо ліміт спроб ще не вичерпано.
Нижче наведено реалізацію для Node.js 18+ та сучасних браузерів. Вона:
використовує fetch;
повторює лише тимчасові HTTP-помилки;
повторює мережеві помилки та тайм-аути;
використовує експоненційний backoff і jitter;
враховує Retry-After;
підтримує тайм-аут кожної спроби;
підтримує зовнішній AbortSignal;
обмежує кількість спроб і загальний час.
const http = require("node:http");
const RETRYABLE_STATUS_CODES = new Set([
408,
425,
429,
500,
502,
503,
504,
]);
function parseRetryAfter(value) {
if (!value) {
return null;
}
// Retry-After може містити кількість секунд.
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1000;
}
// Або HTTP-дату, до якої потрібно зачекати.
const date = Date.parse(value);
if (!Number.isNaN(date)) {
return Math.max(0, date - Date.now());
}
return null;
}
function createTimeoutError(timeout) {
const error = new Error(`Час очікування перевищено: ${timeout} мс`);
error.code = "ETIMEDOUT";
return error;
}
async function fetchAttempt(input, init, timeout, externalSignal) {
const controller = new AbortController();
let timeoutId;
let onExternalAbort;
if (externalSignal) {
if (externalSignal.aborted) {
throw externalSignal.reason ?? new Error("Запит скасовано");
}
onExternalAbort = () => {
controller.abort(externalSignal.reason);
};
externalSignal.addEventListener("abort", onExternalAbort, {
once: true,
});
}
if (Number.isFinite(timeout) && timeout > 0) {
timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
}
try {
return await fetch(input, {
...init,
signal: controller.signal,
});
} catch (error) {
if (externalSignal?.aborted) {
throw externalSignal.reason ?? error;
}
if (controller.signal.aborted) {
throw createTimeoutError(timeout);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (externalSignal && onExternalAbort) {
externalSignal.removeEventListener("abort", onExternalAbort);
}
}
}
function sleep(milliseconds, signal) {
if (milliseconds <= 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let timerId;
const onAbort = () => {
clearTimeout(timerId);
reject(signal.reason ?? new Error("Очікування скасовано"));
};
if (signal?.aborted) {
onAbort();
return;
}
timerId = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function isRetryableNetworkError(error) {
// TypeError є типовою помилкою fetch для мережевих проблем.
// ETIMEDOUT створюється цією реалізацією для тайм-аутів.
return error?.name === "TypeError" || error?.code === "ETIMEDOUT";
}
function calculateBackoff(attempt, baseDelay, maxDelay) {
const exponentialDelay = Math.min(
maxDelay,
baseDelay * 2 ** (attempt - 1),
);
// Full jitter: випадкова затримка від 0 до розрахованої.
return Math.floor(Math.random() * (exponentialDelay + 1));
}
async function fetchWithRetry(input, init = {}, options = {}) {
const {
maxAttempts = 4,
baseDelay = 300,
maxDelay = 5000,
maxElapsedTime = 15000,
timeout = 5000,
signal,
retryableMethods = ["GET", "HEAD", "OPTIONS"],
} = options;
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new TypeError("maxAttempts має бути додатним цілим числом");
}
const method = String(init.method ?? "GET").toUpperCase();
const canRetryMethod = retryableMethods.includes(method);
const startedAt = Date.now();
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (signal?.aborted) {
throw signal.reason ?? new Error("Запит скасовано");
}
const elapsed = Date.now() - startedAt;
if (elapsed >= maxElapsedTime) {
break;
}
try {
const response = await fetchAttempt(input, init, timeout, signal);
const shouldRetryStatus =
canRetryMethod &&
RETRYABLE_STATUS_CODES.has(response.status) &&
attempt < maxAttempts;
if (!shouldRetryStatus) {
return response;
}
// Вивільняємо тіло відповіді перед новою спробою.
await response.body?.cancel();
const retryAfter = parseRetryAfter(
response.headers.get("retry-after"),
);
const backoff = calculateBackoff(
attempt,
baseDelay,
maxDelay,
);
// Retry-After має пріоритет над локальним backoff.
const delay = Math.min(
maxDelay,
retryAfter ?? backoff,
);
const remainingTime = maxElapsedTime - (Date.now() - startedAt);
if (remainingTime <= 0) {
break;
}
await sleep(Math.min(delay, remainingTime), signal);
} catch (error) {
if (signal?.aborted) {
throw signal.reason ?? error;
}
if (!canRetryMethod || !isRetryableNetworkError(error)) {
throw error;
}
lastError = error;
if (attempt >= maxAttempts) {
break;
}
const delay = calculateBackoff(
attempt,
baseDelay,
maxDelay,
);
const remainingTime = maxElapsedTime - (Date.now() - startedAt);
if (remainingTime <= 0) {
break;
}
await sleep(Math.min(delay, remainingTime), signal);
}
}
if (lastError) {
throw lastError;
}
throw new Error("Не вдалося виконати запит у встановлений час");
}
// Локальний сервер для демонстрації: перші дві відповіді тимчасово помилкові.
let requestCount = 0;
const server = http.createServer((request, response) => {
requestCount += 1;
if (requestCount < 3) {
response.writeHead(503, {
"Content-Type": "application/json",
"Retry-After": "0.1",
});
response.end(JSON.stringify({
error: "temporary_failure",
attempt: requestCount,
}));
return;
}
response.writeHead(200, {
"Content-Type": "application/json",
});
response.end(JSON.stringify({
message: "Запит успішно виконано",
attempt: requestCount,
}));
});
server.listen(0, async () => {
const { port } = server.address();
try {
const response = await fetchWithRetry(
`http://127.0.0.1:${port}/data`,
{},
{
maxAttempts: 4,
baseDelay: 100,
maxDelay: 1000,
maxElapsedTime: 5000,
timeout: 1000,
},
);
console.log(response.status);
console.log(await response.json());
} finally {
server.close();
}
});У прикладі локальний сервер повертає 503 для перших двох запитів. Функція читає Retry-After, очікує, а потім повторює запит. Третя спроба отримує статус 200.
Запустити приклад можна так:
node retry-example.jsPOSTЗа замовчуванням реалізація не повторює POST. Якщо API підтримує ідемпотентні POST із ключем, метод можна додати явно:
const idempotencyKey = crypto.randomUUID();
const response = await fetchWithRetry(
"https://api.example.test/payments",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
amount: 2500,
currency: "UAH",
}),
},
{
retryableMethods: ["GET", "HEAD", "OPTIONS", "POST"],
},
);У Node.js для цього прикладу потрібно додати:
const crypto = require("node:crypto");Додавати POST до списку без підтримки ідемпотентності на сервері небезпечно.
Повторні спроби можуть тривати довше за життя UI-компонента або HTTP-запиту користувача. Для скасування використовується AbortController:
const controller = new AbortController();
const timerId = setTimeout(() => {
controller.abort(new Error("Користувач скасував операцію"));
}, 2000);
try {
const response = await fetchWithRetry(
"https://api.example.test/profile",
{},
{
signal: controller.signal,
maxAttempts: 10,
maxElapsedTime: 30000,
},
);
console.log(await response.json());
} catch (error) {
console.error("Операцію завершено:", error.message);
} finally {
clearTimeout(timerId);
}Скасування повинно переривати:
активний fetch;
таймер тайм-ауту;
очікування між спробами.
Інакше після скасування старий код може продовжити створювати запити у фоні.
Retry-AfterЗаголовок Retry-After може мати два формати:
Retry-After: 3Це означає чекати три секунди.
Retry-After: Wed, 21 Oct 2015 07:28:00 GMTЦе означає чекати до вказаної HTTP-дати.
Значення заголовка не варто приймати без обмежень. Некоректний або зловмисний сервер може вказати надто великий час очікування. Тому клієнт має обмежувати його через maxDelay та maxElapsedTime.
У production-коді кожна повторна спроба має бути помітною в логах або метриках. Корисно записувати:
назву операції;
HTTP-метод;
адресу без конфіденційних параметрів;
номер спроби;
причину повторення;
HTTP-статус або код мережевої помилки;
фактичну затримку;
загальну тривалість операції;
фінальний результат.
Не потрібно логувати повне тіло запиту або токени авторизації. Такі дані можуть містити персональну чи конфіденційну інформацію.
Для моніторингу корисні метрики:
частка запитів із повтореннями;
середня кількість спроб;
кількість операцій, що завершилися після retry;
кількість операцій, які вичерпали ліміти;
кількість відповідей 429, 503 та 504.
Зростання кількості повторів часто вказує на проблему із залежним сервісом, навіть якщо фінальна кількість помилок залишається низькою.
Підходить для більшості тимчасових мережевих і серверних помилок:
delay = random(0, min(maxDelay, baseDelay × 2^attempt))Проста стратегія:
500 мс, 500 мс, 500 мсВона може бути достатньою для локальних сценаріїв, але гірше поводиться під великим навантаженням.
300 мс, 600 мс, 900 мс, 1200 мсЗростання повільніше, ніж у експоненційного backoff.
Якщо присутній Retry-After, клієнт може використати значення сервера. Це особливо важливо для 429, коли сервер сам керує швидкістю повторних запитів.
На практиці часто використовують правило:
взяти Retry-After, якщо він коректний;
обмежити його локальним maxDelay;
перевірити, чи не вичерпано загальний дедлайн.
404 або 403 не стануть успішними через повторення. Це збільшує навантаження та приховує справжню причину проблеми.
Цикл із негайними запитами може перевантажити сервер ще сильніше. Між спробами потрібна затримка, бажано з jitter.
Без maxAttempts, maxDelay або maxElapsedTime клієнт може нескінченно чекати або створювати неконтрольовану кількість запитів.
Автоматичний retry для створення платежу, замовлення або повідомлення може виконати операцію кілька разів.
Retry-AfterСервер може явно повідомити, коли повторити запит. Ігнорування цього заголовка часто призводить до повторних 429.
Скасований користувачем запит не потрібно запускати знову. Сигнал скасування має переривати і запит, і затримку.
Одна спроба може зависнути надовго. Загальний дедлайн не замінює тайм-ауту конкретного fetch.
У браузері або Node.js тіло запиту може бути потоком, який неможливо повторно прочитати. Для повторюваних запитів варто використовувати повторно доступні дані, наприклад рядок JSON або Buffer, або заздалегідь створювати новий потік для кожної спроби.
Для операцій оновлення важливо враховувати конкуренцію. Повторний PUT без перевірки версії може перезаписати новіші дані. Для цього API використовують умовні запити, наприклад If-Match з ETag.
Повторні запити призначені для тимчасових мережевих і серверних помилок.
Не кожен HTTP-статус потрібно повторювати.
Типова retryable-група містить 408, 425, 429, 500, 502, 503 і 504.
Для затримок використовують експоненційний backoff із jitter.
Retry-After потрібно враховувати, але обмежувати локальними лімітами.
Кількість спроб і загальний час операції мають бути обмежені.
POST не слід повторювати без ідемпотентності та ключа операції.
Скасування користувача потрібно відрізняти від тимчасової мережевої помилки.
Повторні спроби варто вимірювати й логувати без розкриття конфіденційних даних.