Пошук уроків, статей та іншого контенту
Реалізуємо безпечні повторні спроби з експоненційною затримкою, jitter, лімітами та захистом від retry storm.
Мережеві операції можуть тимчасово завершуватися помилкою через:
короткочасне перевантаження сервісу;
втрату мережевого пакета;
тимчасову недоступність залежності;
перевищення ліміту запитів;
перемикання між вузлами або аварійне відновлення сервісу.
У таких випадках повторна спроба може завершитися успішно. Але без обмежень retry-механізм здатен погіршити ситуацію: клієнти продовжать надсилати запити до перевантаженого сервісу.
Тому безпечний retry має визначати:
які помилки можна повторювати;
скільки спроб дозволено;
яку затримку використовувати між спробами;
як додати випадковість до затримки;
скільки загального часу можна витратити;
як не створити retry storm.
Повторювати слід переважно тимчасові помилки. Наприклад:
мережевий timeout;
розірване з'єднання;
HTTP 408 Request Timeout;
HTTP 429 Too Many Requests;
500502503504Не варто автоматично повторювати:
помилки валідації даних;
помилки автентифікації та авторизації;
HTTP 400, якщо запит сформовано неправильно;
HTTP 404, якщо ресурс гарантовано не з'явиться після очікування.
Остаточне рішення залежить від конкретного API. Наприклад, 404 для щойно створеного ресурсу іноді може бути тимчасовим, але для звичайного читання це зазвичай остаточна помилка.
Операція є ідемпотентною, якщо її повторення не змінює результат після першого успішного виконання.
Типові приклади:
GET зазвичай ідемпотентний;
PUT зазвичай ідемпотентний;
DELETE зазвичай ідемпотентний;
POST не обов'язково ідемпотентний.
Повторення POST може створити два однакові замовлення або дві транзакції. Тому для небезпечних операцій потрібен додатковий захист, наприклад ідемпотентний ключ, який сервер використовує для розпізнавання повторного запиту.
Retry не повинен перетворювати одну логічну операцію користувача на кілька побічних ефектів.
Лінійна затримка збільшується на однакову величину:
100 мс, 200 мс, 300 мс, 400 мсЕкспоненційна затримка збільшується вдвічі після кожної невдалої спроби:
100 мс, 200 мс, 400 мс, 800 мс, 1600 мсБазова формула:
delay = baseDelay × 2^(attempt - 1)де:
baseDelay — початкова затримка;
attempt — номер наступної спроби.
На практиці затримку обмежують зверху:
delay = min(maxDelay, baseDelay × 2^(attempt - 1))Без верхньої межі кілька невдалих спроб можуть призвести до дуже довгого очікування.
Якщо багато клієнтів одночасно отримали помилку, вони можуть повторити запит одночасно. Це створює хвилю навантаження:
сервіс перевантажується;
багато клієнтів отримують помилки;
усі клієнти очікують однаковий час;
усі одночасно повторюють запити;
сервіс знову перевантажується.
Це називають retry storm або thundering herd.
Щоб розсинхронізувати клієнтів, до затримки додають випадковість — jitter.
Спочатку обчислюємо максимальну експоненційну затримку:
cap = min(maxDelay, baseDelay × 2^(attempt - 1))Потім випадково вибираємо фактичну затримку:
delay = random(0, cap)Наприклад, якщо cap = 800 мс, клієнт може зачекати будь-який час від 0 до 800 мс.
Full jitter добре підходить для великої кількості одночасних клієнтів, оскільки зменшує синхронізацію повторних запитів.
Безпечний retry повинен мати кілька незалежних обмежень.
Наприклад:
maxAttempts = 5Важливо заздалегідь визначити, чи це кількість усіх викликів, чи лише повторів. У цьому уроці maxAttempts включає першу спробу.
За maxAttempts = 5 операція може виконатися:
один раз початково;
ще чотири рази повторно.
Навіть експоненційна затримка з jitter повинна мати верхню межу:
maxDelay = 5000 мсЦе запобігає неконтрольованому зростанню паузи.
Ліміту кількості спроб недостатньо. Одна спроба може тривати кілька секунд, тому п'ять спроб можуть заблокувати запит надовго.
Загальний бюджет визначає, скільки часу дозволено витратити на всю операцію:
maxElapsedTime = 15000 мсЯкщо бюджет вичерпано, нову спробу починати не слід.
Нижче наведено реалізацію з такими можливостями:
повторюються лише вибрані помилки;
використовується exponential backoff;
додається full jitter;
є ліміт кількості спроб;
є максимальна затримка;
є загальний часовий бюджет;
підтримується підказка сервера retryAfterMs.
class HttpError extends Error {
constructor(status, message, retryAfterMs = undefined) {
super(message);
this.name = "HttpError";
this.status = status;
this.retryAfterMs = retryAfterMs;
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableError(error) {
if (error instanceof HttpError) {
return (
error.status === 408 ||
error.status === 425 ||
error.status === 429 ||
error.status >= 500
);
}
// Мережеві помилки без HTTP-відповіді вважаємо тимчасовими.
return error && error.retryable === true;
}
async function withRetry(operation, options = {}) {
const {
maxAttempts = 5,
baseDelayMs = 200,
maxDelayMs = 5000,
maxElapsedMs = 15000,
shouldRetry = isRetryableError,
} = options;
const startedAt = Date.now();
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const elapsedBeforeAttempt = Date.now() - startedAt;
if (elapsedBeforeAttempt >= maxElapsedMs) {
throw new Error("Загальний часовий бюджет retry вичерпано");
}
try {
return await operation(attempt);
} catch (error) {
const isLastAttempt = attempt === maxAttempts;
const elapsed = Date.now() - startedAt;
const remainingTime = maxElapsedMs - elapsed;
if (isLastAttempt || !shouldRetry(error) || remainingTime <= 0) {
throw error;
}
const exponentialCap = Math.min(
maxDelayMs,
baseDelayMs * 2 ** (attempt - 1)
);
// Full jitter: випадкова затримка від 0 до exponentialCap.
let delayMs = Math.floor(Math.random() * (exponentialCap + 1));
// Сервер може попросити зачекати не менше певного часу.
if (Number.isFinite(error.retryAfterMs)) {
delayMs = Math.max(
delayMs,
Math.min(error.retryAfterMs, maxDelayMs)
);
}
// Не виходимо за межі загального часового бюджету.
delayMs = Math.min(delayMs, remainingTime);
console.log(
`Спроба ${attempt} завершилася помилкою. ` +
`Наступна спроба через ${delayMs} мс`
);
await sleep(delayMs);
}
}
throw new Error("Retry завершився без результату");
}
// Приклад тимчасово нестабільної операції.
let failuresLeft = 3;
async function loadData(attempt) {
console.log(`Виконуємо спробу ${attempt}`);
if (failuresLeft > 0) {
failuresLeft -= 1;
throw new HttpError(
503,
"Сервіс тимчасово недоступний",
300
);
}
return {
status: "ok",
data: "Дані отримано",
};
}
withRetry(loadData, {
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 2000,
maxElapsedMs: 10000,
})
.then((result) => {
console.log("Результат:", result);
})
.catch((error) => {
console.error("Операція остаточно завершилася помилкою:", error.message);
});У цьому прикладі перші три виклики завершуються помилкою 503, а четвертий — успішно.
Значення затримок випадкові через jitter. Сервер також передає retryAfterMs = 300, тому клієнт не чекає менше ніж 300 мс у цьому прикладі, але все одно не перевищує maxDelayMs.
HTTP-сервер може повідомити клієнту, коли варто повторити запит, через заголовок Retry-After.
Клієнт повинен:
перевірити, чи значення заголовка коректне;
обмежити його власним maxDelay;
не перевищити загальний часовий бюджет;
зберегти jitter, якщо це не суперечить вимозі сервера.
Особливо часто така підказка використовується разом із відповіддю 429 Too Many Requests.
Не варто безмежно довіряти значенню Retry-After. Некоректний або надто великий час очікування не повинен заблокувати клієнта на невизначений строк.
Retry storm виникає не лише через відсутність jitter. Безпечна реалізація поєднує кілька механізмів:
exponential backoff — швидко зменшує частоту повторних запитів;
jitter — розподіляє повтори в часі;
maxAttempts — обмежує кількість запитів від одного клієнта;
maxDelay — обмежує тривалість окремої паузи;
maxElapsedMs — обмежує загальний час операції;
фільтрація помилок — не повторює безнадійні запити;
повага до Retry-After — враховує сигнал від сервера;
ідемпотентність — не допускає дублювання побічних ефектів.
Не слід повторювати кожну помилку лише тому, що запит не завершився успішно. Retry — це механізм для тимчасових збоїв, а не універсальний спосіб обробки всіх винятків.
Параметри залежать від операції та вимог до затримки. Як початкову конфігурацію можна розглядати:
maxAttempts = 3–5
baseDelay = 100–500 мс
maxDelay = кілька секунд
maxElapsedTime = відповідно до timeout операціїВажливо, щоб retry-бюджет узгоджувався з timeout верхнього рівня. Наприклад, якщо HTTP-запит має timeout 2 секунди, retry із бюджетом 15 секунд не має практичного сенсу: зовнішній виклик завершиться раніше.
Також потрібно враховувати, що retry збільшує навантаження:
1 початковий запит + 4 повтори = до 5 запитівЯкщо один користувацький запит викликає кілька внутрішніх сервісів, множення retry на кожному рівні може різко збільшити кількість запитів. Тому retry бажано контролювати на одному відповідальному рівні, а не бездумно вмикати його в кожному шарі.
Безпечний алгоритм можна описати так:
Виконати операцію.
Якщо вона успішна — повернути результат.
Якщо помилка не є тимчасовою — одразу повернути помилку.
Якщо досягнуто ліміт спроб або часу — повернути помилку.
Обчислити exponential backoff.
Додати jitter.
Врахувати Retry-After, якщо сервер його надав.
Почекати, не виходячи за межі бюджету.
Повторити операцію.
try {
return await operation();
} catch {
return await operation();
}Такий код може повторювати помилки валідації, дозволів або некоректні запити.
Потрібно мати явну функцію shouldRetry, яка визначає тимчасові помилки.
500 мс, 500 мс, 500 мс, 500 мсФіксована затримка не розсинхронізовує клієнтів. Велика кількість клієнтів може повторити запити одночасно.
Краще використовувати exponential backoff і jitter.
100 мс, 200 мс, 400 мс, 800 мсЯкщо клієнти отримали помилку одночасно, вони й надалі залишаться синхронізованими. Експоненційна формула сама по собі не захищає від retry storm.
Без maxDelay затримка може стати надто великою, а без maxElapsedMs операція може чекати довше, ніж дозволяє продуктова вимога.
Повторення операції запису може створити дублікати. Для таких операцій потрібен ідемпотентний протокол між клієнтом і сервером.
Якщо сервер повернув 429 і повідомив Retry-After, негайний повтор може продовжити перевантаження. Цей сигнал потрібно враховувати, але обмежувати власними лімітами.
Retry призначений для тимчасових помилок, а не для всіх винятків.
Експоненційна затримка зменшує частоту повторних запитів.
Jitter розсинхронізовує клієнтів і допомагає уникати retry storm.
Потрібно обмежувати кількість спроб, максимальну затримку та загальний час.
Retry-After може повідомити рекомендований сервером час очікування.
Неідемпотентні операції не можна безпечно повторювати без додаткового захисту.
Retry має бути частиною загального бюджету timeout і навантаження системи.