🏗️ Розділ 17 · Питання #19

Що таке патерн Retry і як його правильно використовувати

Retry — це «двосічний меч». Якщо віддалений сервіс упав під високим навантаженням, мільйони автоматичних повторних спроб миттєво потроять трафік і спричинять Retry Storm (лавину...


🟢 Junior Level

Retry (патерн повторних спроб) — це механізм стійкості, який автоматично повторює виконання невдалої мережевої операції, розраховуючи на те, що збій має короткочасний характер (Transient Fault) і усунеться сам собою через невеликий проміжок часу.

Мережевий збій (Network Blip):
[Client] ──1. Request──► [Payment Service] ──(Мережеве миготіння: Connection Timeout ❌)
[Client] ──(Пауза 500мс)
[Client] ──2. Retry────► [Payment Service] ──(Мережа відновилася: 200 OK ✅)

Коли Retry допомагає:

  • Короткочасні мережеві флуктуації (втрата TCP-пакета, тимчасова ребалансування комутатора/маршрутизатора).
  • Перезавантаження одного пода за балансувальником навантаження (наступний запит балансувальник спрямує на працездатний под).
  • Короткочасне блокування рядка в базі даних (OptimisticLockException або тимчасовий deadlock).

Головне застереження:

Retry — це «двосічний меч». Якщо віддалений сервіс упав під високим навантаженням, мільйони автоматичних повторних спроб миттєво потроять трафік і спричинять Retry Storm (лавину повторів), остаточно добивши перевантажену систему.


🟡 Middle Level

1. Золоте правило: Ідемпотентність

КАТЕГОРИЧНА ЗАБОРОНА: Патерн Retry НЕ МОЖНА використовувати для неідемпотентних операцій без механізму гарантії унікальності!

  • Якщо запит GET /orders/1 впав за таймаутом — його безпечно повторити хоч 3 рази (читання є ідемпотентним).
  • Якщо запит POST /api/v1/payments/charge впав за таймаутом — достеменно невідомо, чи виконався платіж:
    1. Або пакет загубився дорогою до банку $\to$ списання не відбулося.
    2. Або банк списав кошти, але пакет відповіді втрачено на зворотному шляху. Якщо просто повторити виклик без Idempotency-Key, банк спише кошти вдруге.
// Безпечний виклик з Retry та Idempotency-Key
public PaymentResponse chargeWithRetry(PaymentRequest request) {
    String idempotencyKey = UUID.randomUUID().toString(); // Унікальний ключ транзакції
    
    return retryRegistry.retry("paymentRetry").executeSupplier(() -> 
        restClient.post()
            .uri("/charge")
            .header("X-Idempotency-Key", idempotencyKey)
            .body(request)
            .retrieve()
            .body(PaymentResponse.class)
    );
}

2. Класифікація помилок: Retryable vs Non-Retryable

Тип помилки HTTP-код / Виняток Чи робити Retry? Чому
Мережеві збої ConnectException, SocketTimeoutException ТАК Тимчасова недоступність маршруту або короткочасна затримка.
Перевантаження сервісу HTTP 503 Service Unavailable, HTTP 429 Too Many Requests ТАК (з Backoff) Сервіс відновиться, особливо якщо вказано заголовок Retry-After.
Помилки клієнта HTTP 400 Bad Request, HTTP 404 Not Found, HTTP 422 НІ ❌ Неправильний синтаксис чи ідентифікатор. Повторний запит поверне ту саму помилку.
Помилки безпеки HTTP 401 Unauthorized, HTTP 403 Forbidden НІ ❌ Протермінований токен чи відсутність прав не виправляться повтором.
Бізнес-помилки InsufficientFundsException (Недостатньо коштів) НІ ❌ Гроші на рахунку магічно не з’являться через 500 мілісекунд.

🔴 Senior Level

1. Retry Storm (Шторм повторів) та Retry Budget

Під час аварії одного сервісу в ланцюжку поведінка тисяч клієнтів із наївним Retry спричиняє лавиноподібне перевантаження:

Звичайний режим: 1 000 RPS ──► [Service B] (Працює)
Аварія:          1 000 RPS ──(Fail)──► [Кожен клієнт робить 3 повтори!]
Результат:       4 000 RPS ──► [Service B] (Остаточна загибель від самострілу 💥)

Захисні механізми:

  1. Exponential Backoff із Jitter (Джиттер / випадкове тремтіння): Кожна наступна спроба виконується зі збільшеним інтервалом ($200\text{мс} \to 400\text{мс} \to 800\text{мс}$) плюс випадковий часовий розкид, що розмазує натовп клієнтів у часі.
  2. Retry Budget (Бюджет повторів): Клієнтський балансувальник навантаження (наприклад, в Envoy / Finagle) веде статистику: повторні спроби дозволені лише за умови, що їх частка становить не більше 10% від загальної кількості запитів. Якщо збоїть понад 10% викликів, повтори повністю вимикаються, перетворюючись на швидкий збій (Fail-Fast).
  3. Поєднання з Circuit Breaker: Якщо Circuit Breaker переходить у стан OPEN, виклики миттєво перериваються ще до виконання першої спроби, запобігаючи запуску марного циклу Retry.

2. Deadline Propagation (Наскрізний контроль дедлайну)

Класична прихована помилка: «Марні повторні спроби» (Useless Retries):

  • Користувач у браузері надіслав запит. Загальний таймаут API Gateway на весь потік становить 3 секунди.
  • Внутрішній мікросервіс $C$ на 3-й спробі очікує експоненційну паузу у 4 секунди.
  • Доки мікросервіс $C$ спав і повторював запит, шлюз на периметрі вже закрив TCP-з’єднання з клієнтом і повернув користувачеві помилку 504 Gateway Timeout.
  • Уся подальша робота сервісу $C$ та його ретраїв — це даремно витрачені ресурси CPU, пам’яті та пулу з’єднань БД. Рішення: Deadline Propagation (gRPC Deadlines / HTTP-заголовки X-Request-Deadline). Сервіс перевіряє залишок часу до дедлайну. Якщо $T_{remaining} < \text{backoffDuration}$, спроба негайно скасовується.

4 Tricky Questions

1. Чому в Spring Retry анотація @Retryable на методі не спрацьовує при виклику з того самого класу (this.method())?

Відповідь: Spring Retry реалізований через стандартний механізм Spring AOP Dynamic Proxy. Коли зовнішній компонент інжектує бін через @Autowired, він викликає метод у згенерованого проксі-об’єкта, який перехоплює виклик і застосовує логіку повторів. Якщо метод a() викликає метод b() у межах одного й того самого класу (this.b()), звернення відбувається безпосередньо до цільового екземпляра в пам’яті в обхід Spring-проксі. У результаті анотації @Retryable та @Recover на методі b() повністю ігноруються. Рішення: Винести метод в окремий Spring-сервіс або інжектувати залежність на самого себе (self-injection) через ApplicationContext.

2. Що станеться, якщо поєднати Spring @Transactional та @Retryable у неправильному порядку?

Відповідь: Якщо анотація @Transactional розташована зовні @Retryable: У разі помилки під час першої спроби транзакція в Hibernate/JPA позначається як Rollback-Only, а EntityManager переходить у пошкоджений стан. Усі наступні спроби повтору падатимуть із винятком Transaction silently rolled back або Transaction rolled back because it has been marked as rollback-only, навіть якщо зовнішня причина збою вже зникла. Правило: @Retryable ЗОБОВ’ЯЗАНИЙ бути зовні @Transactional. Кожна нова спроба повинна відкривати абсолютно нову, чисту транзакцію БД:

@Retryable(retryFor = {TransientDataAccessException.class})
@Transactional // Кожна спроба відкриває чистий BEGIN / COMMIT
public void processPayment() { /* ... */ }

3. Як правильно обробляти HTTP-відповідь 429 Too Many Requests у клієнті з Retry?

Відповідь: Стандартні клієнти при помилці 429 не повинні використовувати сліпий експоненційний backoff. Сервер, який захищається від перевантаження, відправляє точний час очікування в заголовку відповіді: Retry-After: 120 (секунди) або у форматі дати HTTP-Date. Клієнт зобов’язаний вилучити заголовок Retry-After і заснути рівно на вказаний інтервал. Якщо повторювати запити раніше вказаного сервером часу, шлюз безпеки (Cloudflare / WAF / Envoy) може тимчасово заблокувати IP клієнта за порушення політик API.

4. У чому різниця між стратегіями Full Jitter та Equal Jitter (згідно з дослідженням AWS Architecture)?

Відповідь:

  • Без Jitter: sleep = base * 2^attempt (клієнти бомбардують сервер синхронними хвилями).
  • Equal Jitter: half = sleep / 2; sleep = half + random(0, half) (половина часу фіксована, друга половина випадкова; зменшує амплітуду хвиль, але зберігає періодичність).
  • Full Jitter: sleep = random(0, base * 2^attempt) (інтервал повністю випадковий від 0 до обчисленого експоненційного максимуму). Дослідження AWS довели, що Full Jitter забезпечує найкращий захист від проблеми конкуруючого натовпу (Thundering Herd), повністю руйнуючи резонанс повторних запитів і забезпечуючи мінімальний сумарний час відновлення серверів.

🎯 Шпаргалка для інтерв’ю

Головне:

  • Retry — автоматичний повтор під час короткочасних технічних збоїв (Timeout, 503, ConnectException).
  • Залізне правило: Retry застосовується ТІЛЬКИ до ідемпотентних операцій (або з обов’язковим Idempotency-Key).
  • Ніколи не ретраїти: 4xx помилки, помилки авторизації, бізнес-валідації.
  • Обов’язково використовувати Exponential Backoff із Full Jitter, щоб не спричинити Retry Storm.
  • Retry Budget: Обмеження частки повторів до 10% від загального обсягу викликів.
  • У Spring: @Retryable повинен бути зовні @Transactional!

Червоні прапорці (Чого говорити НЕ МОЖНА):

  • ❌ «Ми налаштували Retry на метод списання грошей з картки без Idempotency Key» (Призведе до подвійних списань під час мережевих таймаутів).
  • ❌ «Якщо впало з 404 Not Found, ми пробуємо ще 3 рази» (404 вказує на відсутність ресурсу; повтори безглузді).
  • ❌ «Повторювати запит треба миттєво, без жодних пауз» (Миттєвий повтор доб’є збійний сервіс лавиною запитів).

Пов’язані теми: