1. Навіщо потрібен retry і чим він небезпечний
Retry зазвичай з’являється в проєкті так: ви один раз ловите мережевий збій, потім другий, третій — і хтось урочисто каже: «А давайте просто повторимо запит». На рівні емоцій ідея зрозуміла: інтернет мигнув, Wi‑Fi чхнув, сервер замислився — повторили, і все поїхало далі. Але в програмуванні є традиція: щойно ідея «зрозуміла і проста», вона обов’язково має два приховані підводні камені й одну підступну пастку.
Перший підводний камінь — не всі запити можна безпечно повторювати. Якщо повтор запиту може створити побічний ефект, наприклад двічі створити замовлення, двічі списати гроші або двічі додати книгу до віддаленої колекції, то retry перетворюється на генератор дублікатів і користувацького гніву. І це не «рідкісний випадок», а нормальна реальність будь-якого застосунку, який хоч щось змінює.
Другий підводний камінь — не всі помилки можна вилікувати повтором. Якщо сервер повернув валідну відповідь, але ви не змогли її розпарсити (помилка декодування), повтор найчастіше принесе той самий формат. Отже, ви просто двічі або навіть десять разів наступите на ті самі граблі, витративши час користувача й батарейку його ноутбука.
Підступна пастка — нескінченний цикл. У нього легко потрапити, якщо «повторюємо, доки не вийде», а «не виходить» у вас — це, наприклад, 401 Неавторизовано. Тобто ви зробили неправильний запит і тепер героїчно повторюєте його до кінця часів або доти, доки не спрацює watchdog.
Щоб retry був корисним, він має відповідати на два запитання:
- чи безпечно повторювати операцію за змістом, тобто чи є вона ідемпотентною;
- чи є шанс, що повтор виправить конкретну категорію помилки.
2. Ідемпотентність і критерії безпечного повторення
Ідемпотентність: «повторити — означає не зробити гірше»
Коли програмісти кажуть «ідемпотентно», вони зазвичай звучать так, ніби в них у руках диплом із теоретичної математики. Але ідея дуже побутова: якщо зробити одну й ту саму дію двічі, результат має бути таким самим, як і після одного виконання.
Найприземленіший приклад із реального життя — кнопка «вимкнути світло». Натиснули один раз — світло вимкнулося. Натиснули вдруге — світло все ще вимкнене, і світ не «вимкнувся сильніше». Оце й є ідемпотентність.
У світі API це означає: повторний запит не повинен призводити до додаткових ефектів. І так, це слово вживають не лише в мережах — у стандартній бібліотеці Swift теж трапляється поняття «виклик є ідемпотентним». Наприклад, в описах AsyncStream сказано, що повторне завершення потоку вважається ідемпотентною дією: вдруге «завершити завершене» не змінює стан.
До речі, коли люди будують системи надійності на рівні інфраструктури, наприклад рушії робочих процесів або системи оркестрації, вони прямо вимагають ідемпотентності від дій, які повторюватимуться. Інакше повтори перетворюються на катастрофу. Наприклад, у документації Temporal підкреслюють, що дії мають бути ідемпотентними, щоб їх можна було безпечно повторювати під час збоїв.
Для нас практичне правило сьогодні звучить так: перед тим як вирішувати «повторюємо чи ні», треба зрозуміти, чи не створюємо ми дублікат дії.
Евристика за HTTP-методом
Коли у вас на руках лише URLRequest, найпростіший сигнал щодо того, чи можна повторювати запит, — це HTTP-метод. Це не ідеальна модель світу, але дуже корисна стартова евристика.
Зазвичай вважається, що GET — найбезпечніший для retry, бо він «читає» дані. PUT і DELETE за специфікацією частіше трактуються як ідемпотентні: повторне «встанови значення X» або «видали ресурс» не повинно призводити до множинних ефектів. А ось POST найчастіше неідемпотентний, бо створює щось нове. На практиці, звісно, бувають API, де POST зроблено ідемпотентним через спеціальний ключ, але це вже домовленість конкретного сервера.
Зробімо в нашому LibraryCLI невеликий тип HTTPMethod та евристику. Це простий будівельний блок: ми не намагаємося «довести ідемпотентність», а лише даємо розумне правило за замовчуванням.
Приклад (Swift 6.2):
import Foundation
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
extension HTTPMethod {
var isUsuallyIdempotent: Bool {
switch self {
case .get, .put, .delete: return true
case .post: return false
}
}
}
Зверніть увагу на тонкість: ми назвали властивість isUsuallyIdempotent, а не isIdempotent. Це чесно. Програмування й так сповнене напівправд, тож давайте хоча б у неймінгу не підливати олії у вогонь.
Канонічний NetworkError: категорії, які допомагають ухвалювати рішення
Якщо retry — це політика, то їй потрібне паливо: зрозуміла класифікація причин. Саме тому ми вже запровадили NetworkError: щоб мережеві проблеми не виглядали як «якась помилка з текстом».
Зафіксуймо в таблиці, як мислити про категорії NetworkError саме з погляду retry. У цій лекції ми не пишемо backoff і не обговорюємо таймаути — лише вирішуємо, повторювати запит чи ні.
| Категорія NetworkError | Інтуїтивний зміст | Чи часто допомагає retry |
|---|---|---|
|
«не вдалося доставити запит / немає зв’язку / TLS / DNS / таймаут транспорту» | часто так |
|
«відповідь прийшла, але це не HTTP або вона має дивний формат» | іноді так |
|
«сервер відповів коректно, але статус-код каже “ні”» | залежить від коду |
|
«відповідь коректна, але ми не змогли розпарсити JSON» | майже ніколи |
Це не магія й не релігія, а просто інженерна логіка: retry корисний там, де причина тимчасова. Якщо причина в тому, що ми сформували неправильний запит, повторення неправильного запиту не зробить його правильним.
Які помилки вважати придатними для повторення
Тепер зберімо другий фільтр: навіть якщо операція ідемпотентна, ми все одно не хочемо повторювати будь-яку помилку. Нам потрібна функція, яка каже: «у цієї помилки є шанс зникнути сама».
У нашому курсі ми вже домовилися, що мережевий шар видає NetworkError. Отже, придатність до повторення ми класифікуємо саме за ним. Важливо: якщо прилетіла помилка, яка взагалі не є NetworkError, у базовому варіанті ми її не повторюємо. Інакше можна випадково почати повторювати помилки доменної логіки, а це зазвичай зовсім інший всесвіт причин і наслідків.
Приклад (ідея класифікації):
import Foundation
func isRetryableNetworkError(_ error: Error) -> Bool {
guard let net = error as? NetworkError else { return false }
switch net {
case .transport:
return true
case .invalidResponse:
return true
case .httpStatus(let code, _):
return (500...599).contains(code) || code == 429
case .decoding:
return false
}
}
Тут є два важливі рішення, які варто проговорити словами.
Перше: 5xx ми вважаємо тимчасовою проблемою сервера. Це не гарантія, але шанс є: перевантаження, деплой, короткий збій залежності — усе це іноді минає через кілька секунд.
Друге: 429 Занадто багато запитів ми теж вважаємо придатним для повторення, тому що зміст коду якраз такий: «зачекай і спробуй пізніше». Так, в ідеальному світі ми ще читаємо Retry-After, але сьогодні тримаємо модель простою та передбачуваною.
А от decoding ми не повторюємо. Якщо JSON зламаний або має неочікувану схему, повтор майже завжди поверне той самий формат. Інакше кажучи, це не «мережа мигнула», а «наш контракт з API не збігається».
3. Реалізація retry: стоп-умови й обгортка над операцією
Стоп-умови retry-циклу
Коли люди вперше пишуть retry, вони часто роблять його нескінченним, іноді навіть несвідомо. Тому нам потрібні стоп-умови. І тут є приємна новина: стоп-умови — це не список із 17 пунктів, а лише три здорові обмеження.
По-перше, ми обмежуємо кількість спроб. Це банально, але рятує від сценарію «зависло назавжди». По-друге, ми припиняємо retry, якщо помилка непридатна для повторення. По-третє, ми поважаємо скасування задачі: якщо хтось зверху сказав «не треба», значить справді не треба.
Зберімо це в невеликий тип даних RetryPolicy. Він зберігатиме параметри, а не логіку, щоб код не перетворився на набір «магічних чисел» усередині циклу.
import Foundation
struct RetryPolicy {
let maxAttempts: Int
init(maxAttempts: Int) {
precondition(maxAttempts >= 1)
self.maxAttempts = maxAttempts
}
}
Так, precondition — це не перевірка користувацького введення. Це захист від наших власних помилок: політика з maxAttempts = 0 не має сенсу, і краще нехай застосунок упаде на етапі розробки, ніж тихо робитиме щось дивне у продуктивному середовищі.
Універсальна обгортка retrying(...)
Найзручніший формат retry — це функція, яка приймає async throws операцію та вирішує, як її повторювати. Тоді retry можна застосовувати до різних запитів однаково, не копіюючи логіку циклу у всьому проєкті.
Зверніть увагу: зараз ми не додаємо затримок між спробами. Ми спеціально робимо «голий» retry: спроба → помилка → рішення → наступна спроба. Це важливо, бо інакше легко змішати дві ідеї в одну кашу: «коли повторювати» і «як чекати між спробами». Сьогодні ми вирішуємо лише перше.
Приклад (короткий і практичний):
import Foundation
func retrying<T>(
policy: RetryPolicy,
isRetryable: (Error) -> Bool,
operation: () async throws -> T
) async throws -> T {
for attempt in 1...policy.maxAttempts {
try Task.checkCancellation()
do { return try await operation() }
catch {
if attempt == policy.maxAttempts || !isRetryable(error) { throw error }
}
}
fatalError("Неможливо")
}
Зверніть увагу на кілька дрібниць, які насправді зовсім не дрібниці.
Ми викликаємо Task.checkCancellation() на початку кожної спроби. Це робить retry ввічливим: якщо користувач перервав операцію або застосунок завершує сценарій, ми не продовжуємо стукати в мережу.
Ми викидаємо назовні останню реальну помилку, а не підміняємо її чимось на кшталт RetryFailedError. Це корисно для діагностики: якщо після всіх спроб усе одно 503, назовні має піти саме 503.
І так, fatalError("Неможливо") тут доречний: логіка циклу гарантує, що ми або повернемо значення, або кинемо помилку. Якщо ми дійшли до кінця, значить, десь зламали інваріант циклу, а це вже помилка розробника.
Підсумкове рішення: “повторюємо чи ні”
У нас тепер два фільтри: ідемпотентність за змістом і помилка, придатна для повторення, за причиною. Залишилося склеїти їх в одну функцію, яку зручно читати.
Нам потрібен спосіб дістати HTTP-метод із URLRequest. У URLRequest метод зберігається в рядку httpMethod, і там легко припуститися помилки. Тому в мережевому шарі зручно зробити невеликий парсер: рядок → HTTPMethod.
import Foundation
func httpMethod(from request: URLRequest) -> HTTPMethod {
HTTPMethod(rawValue: request.httpMethod ?? "GET") ?? .get
}
Тепер можемо зібрати рішення:
import Foundation
func shouldRetry(request: URLRequest, error: Error) -> Bool {
let method = httpMethod(from: request)
guard method.isUsuallyIdempotent else { return false }
return isRetryableNetworkError(error)
}
Це саме те, чого ми прагнули: рішення читається як речення.
Спочатку: «метод зазвичай ідемпотентний?» Якщо ні — стоп. Потім: «помилка взагалі має сенс для retry?» Якщо так — можна повторити.
4. Інтеграція retry в LibraryCLI
Обгортка над HTTPClient
Тепер давайте додамо retry в наш застосунок так, щоб архітектура не перетворилася на локшину. Найзручніший спосіб — зробити обгортку над HTTPClient: зовні вона поводиться як звичайний клієнт, а всередині повторює спроби.
Важливо: ця обгортка зберігає лише політику й базовий клієнт. Вона не повинна знати про JSON, DTO і домен, інакше ми зламаємо межу транспортного шару.
Приклад (скелет):
import Foundation
final class RetryingHTTPClient: HTTPClient {
private let base: HTTPClient
private let policy: RetryPolicy
init(base: HTTPClient, policy: RetryPolicy) {
self.base = base
self.policy = policy
}
}
Тепер реалізація send. Тут ми використовуємо нашу retrying(...), а рішення про повтор прив’язуємо до URLRequest і NetworkError.
import Foundation
extension RetryingHTTPClient {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
try await retrying(policy: policy, isRetryable: { err in
shouldRetry(request: request, error: err)
}) {
try await base.send(request)
}
}
}
Тут знову важливий архітектурний момент: ми повторюємо лише те, що належить до мережевого шару. Якщо base.send кидає NetworkError.transport, повторимо. Якщо він кидає щось інше, наприклад внутрішню помилку побудови запиту, — не повторимо, бо isRetryableNetworkError поверне false.
Схема ухвалення рішення
Коли ви читаєте код retry, мозок легко перевантажується: спроби, умови, помилки, скасування. Тому корисно мати просту блок-схему, яка описує саме рішення «повторюємо / не повторюємо».
flowchart TD
A[Отримали помилку] --> B{Метод запиту зазвичай ідемпотентний?}
B -- ні --> X[Не повторюємо: кидаємо помилку]
B -- так --> C{Цю помилку можна повторити за NetworkError?}
C -- ні --> X
C -- так --> D{Це остання спроба?}
D -- так --> X
D -- ні --> E[Робимо наступну спробу]
Якщо ви можете пояснити retry за цією схемою, значить, ви не просто скопіювали код десь, а справді розумієте, як він працює.
5. Типові помилки
Помилка №1: повторювати неідемпотентні операції «тому що мережа».
Таке часто трапляється, коли retry прикручують «внизу», не дивлячись на зміст запиту. У результаті повторний POST може створити дублікат сутності або повторно списати гроші. Навіть якщо конкретно в нашому LibraryCLI ми зараз не робимо платежі, звичка залишається: якщо сьогодні ви навчитеся повторювати все підряд, завтра ви повторюватимете все підряд у продуктивному середовищі. Лікується це лише одним способом: перша умова retry — ідемпотентність або явний дозвіл на повторення за контрактом API, і вона важливіша за статус і текст помилки.
Помилка №2: повторювати помилки декодування.
Іноді здається: «ну раптом удруге прийде нормальний JSON». На практиці це рідко виявляється правдою: якщо сервер зламав формат або ви неправильно описали модель, повтор принесе ті самі байти й ту саму помилку. Повторювати помилки декодування — означає витрачати спроби, час і створювати враження, що застосунок гальмує. Правильніше вважати це непридатним для повторення і піднімати проблему вище: або баг на сервері, або контракт не збігся.
Помилка №3: нескінченні повтори без ліміту спроб.
Повтори без maxAttempts здаються «надійністю», доки ви не побачите завислу CLI-команду, яка «просто чекає». У такому режимі CLI-інструмент перетворюється на чорну скриньку: користувач не розуміє, воно живе чи вже зависло. Ліміт спроб — обов’язкова частина будь-якої політики, навіть якщо він маленький.
Помилка №4: загубити реальну причину помилки, замінивши її «помилкою повторів».
Іноді роблять так: після всіх спроб кидають RetryFailed. Діагностиці від цього сумно: ви втрачаєте конкретику (503? 429? transport?). Набагато корисніше віддати назовні останню фактичну помилку, а сам факт повторів логувати або передавати окремим контекстом, але не підміняти причину.
Помилка №5: ігнорувати скасування задачі всередині retry-циклу.
Якщо не робити Task.checkCancellation(), то скасована задача продовжить «дотискати» всі спроби, особливо якщо всередині є очікування або просто довгі мережеві операції. Це виглядає як баг: користувач скасував команду, а вона продовжує жити своїм життям. Кооперативне скасування в Swift працює добре, але лише якщо ви даєте йому шанс спрацювати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ