1. Зачем нужен retry и чем он опасен
Retry обычно появляется в проекте так: вы один раз ловите сетевой сбой, второй раз, третий — и кто-то говорит сакральное «а давайте просто повторим запрос». На уровне эмоций идея понятная: интернет моргнул, Wi‑Fi чихнул, сервер задумался — повторили, и всё поехало дальше. Но в программировании есть традиция: как только идея «понятная и простая», она обязательно имеет два скрытых подвоха и одну ловушку в кустах.
Первый подвох — не все запросы можно безопасно повторять. Если повтор запроса может создать побочный эффект (например, дважды создать заказ, дважды списать деньги, дважды добавить книгу в удалённую коллекцию), то retry превращается в генератор дублей и пользовательского гнева. И это не «редкий случай», а нормальная реальность любого приложения, которое хоть что-то меняет.
Второй подвох — не все ошибки лечатся повтором. Если сервер вернул валидный ответ, но вы его не смогли распарсить (ошибка декодирования), повтор чаще всего вернёт тот же самый формат, а значит вы просто дважды (или десять раз) наступите на те же грабли, потратив время пользователя и батарейку его ноутбука.
Ловушка в кустах — бесконечный цикл. В нём легко оказаться, если «повторяем пока не получится», а «не получается» у вас — это, например, 401 Unauthorized. То есть вы сделали неправильный запрос, и теперь героически повторяете неправильный запрос до конца времён (или пока не сработает watchdog).
Чтобы retry был полезным, он должен отвечать на два вопроса:
- безопасно ли повторять по смыслу операции (идемпотентность);
- есть ли шанс, что повтор исправит конкретную категорию ошибки.
2. Идемпотентность и критерии безопасного повтора
Идемпотентность: «повторить — значит не сделать хуже»
Когда программисты говорят «идемпотентно», они обычно звучат так, будто в руках у них диплом по теоретической математике. Но идея очень бытовая: если сделать одно и то же действие два раза, результат должен быть таким же, как если сделать один раз.
Самый приземлённый пример из реальной жизни — кнопка «выключить свет». Нажали один раз — свет выключился. Нажали второй раз — свет всё ещё выключен, и мир не «выключился сильнее». Вот это идемпотентность.
В мире API это значит: повторный запрос не должен приводить к дополнительным эффектам. И да, это слово используется не только в сетях — в стандартной библиотеке Swift тоже встречается понятие «вызов идемпотентен». Например, в описаниях AsyncStream говорится, что завершение потока повторным вызовом считается идемпотентным действием: второй раз «завершить завершённое» не меняет состояние.
Интересный факт: когда люди делают системы надёжности на уровне инфраструктуры (workflow-движки, оркестрация), они прямо требуют идемпотентности от действий, которые будут ретраиться, потому что иначе ретраи превращаются в катастрофу. Например, в описаниях подхода Temporal подчёркивается, что activities should be idempotent, чтобы их можно было безопасно повторять при сбоях.
Для нас практическое правило сегодня звучит так: перед тем как решать “ретраим или нет”, нужно понять, не создаём ли мы дубль действия.
Эвристика по 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 полезен там, где причина временная. Если причина «мы сделали неправильный запрос», повторить неправильный запрос — не станет правильным.
Какие ошибки считать retryable
Теперь соберём «второй фильтр»: даже если операция идемпотентна, мы всё равно не хотим ретраить любую ошибку. Нам нужна функция, которая говорит: «эта ошибка имеет шанс исчезнуть сама».
В нашем курсе мы уже договорились, что сетевой слой выдаёт 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 Too Many Requests мы тоже считаем retryable, потому что смысл кода как раз «подожди и попробуй позже». Да, в идеальном мире мы ещё читаем Retry-After, но сегодня мы держим модель простой и предсказуемой.
А вот decoding мы не ретраим. Если JSON «сломанный» или неожиданной схемы, повтор почти всегда вернёт тот же формат. Иными словами: это не «сеть моргнула», это «наш контракт с API не совпал».
3. Реализация retry: стоп-условия и обёртка над операцией
Стоп-условия retry-цикла
Когда люди впервые пишут retry, они часто делают его бесконечным (иногда неосознанно). Поэтому нам нужны стоп-условия. И здесь есть приятная новость: стоп-условия — это не список из 17 пунктов, а всего три здравых ограничения.
Во-первых, мы ограничиваем число попыток. Это банально, но спасает от “повисло навсегда”. Во-вторых, мы прекращаем retry, если ошибка non‑retryable. В-третьих, мы уважаем отмену задачи: если кто-то сверху сказал «не надо», значит действительно не надо.
Соберём это в маленький тип данных 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("Unreachable")
}
Обратите внимание на несколько «мелочей», которые на деле совсем не мелочи.
Мы вызываем Task.checkCancellation() в начале каждой попытки. Это делает retry «вежливым»: если пользователь прервал операцию или приложение завершает сценарий, мы не продолжаем долбиться в сеть.
Мы выбрасываем наружу последнюю реальную ошибку, а не подменяем её на что-то вроде RetryFailedError. Это полезно для диагностики: если после всех попыток всё равно 503, пусть снаружи будет именно 503.
И да, fatalError("Unreachable") здесь допустим: логика цикла гарантирует, что мы либо вернём значение, либо бросим ошибку. Если мы дошли до конца — значит, где-то сломали инвариант цикла, и это уже ошибка разработчика.
Итоговое решение: “ретраим или нет”
У нас теперь два фильтра: идемпотентность (по смыслу) и retryable-ошибка (по причине). Осталось склеить их в одну функцию, которую удобно читать.
Нам нужен способ узнать 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(...), а решение о retry завязываем на 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[Не повторяем throw error]
B -- да --> C{Ошибка retryable по NetworkError?}
C -- нет --> X
C -- да --> D{Это последняя попытка?}
D -- да --> X
D -- нет --> E[Делаем следующую попытку]
Если вы можете объяснить retry этой схемой — значит, вы не просто «скопировали код из интернета», а реально понимаете, что он делает.
5. Типичные ошибки
Ошибка №1: ретраить неидемпотентные операции “потому что сеть”.
Такое часто происходит, когда retry прикручивают «внизу», не глядя на смысл запроса. В результате повторный POST может создать дубль сущности или повторно списать деньги. Даже если конкретно в нашем LibraryCLI мы сейчас не делаем платежи, привычка остаётся: если сегодня вы научитесь ретраить всё подряд, завтра вы ретраите всё подряд в проде. Лечится это только одним способом: первое условие retry — идемпотентность (или явное разрешение на повтор по контракту API), и оно важнее статуса и текста ошибки.
Ошибка №2: ретраить ошибки декодирования.
Иногда кажется: «ну вдруг второй раз придёт нормальный JSON». На практике это редко правда: если сервер сломал формат или вы неверно описали модель, повтор принесёт те же байты и ту же ошибку. Ретраить decoding — значит тратить попытки, время и создавать ощущение “приложение тупит”. Правильнее считать это non‑retryable и поднимать проблему выше: либо баг на сервере, либо контракт не совпал.
Ошибка №3: бесконечные ретраи без лимита попыток.
Ретраи без maxAttempts кажутся “надёжностью”, пока вы не увидите зависшую команду CLI, которая «просто ждёт». CLI-инструмент в таком режиме превращается в чёрный ящик: пользователь не понимает, оно живое или умерло. Лимит попыток — обязательная часть любой политики, даже если он маленький.
Ошибка №4: потерять реальную причину ошибки, заменив её “ошибкой ретраев”.
Иногда делают так: после всех попыток бросают RetryFailed. Диагностике от этого грустно: вы теряете конкретику (503? 429? transport?). Гораздо полезнее наружу отдавать последнюю фактическую ошибку, а сам факт ретраев логировать или прокидывать отдельным контекстом (но не подменять причину).
Ошибка №5: игнорировать отмену задачи внутри retry-цикла.
Если не делать Task.checkCancellation(), то отменённая задача продолжит “дожимать” все попытки, особенно если внутри есть ожидания (или просто длинные сетевые операции). Это выглядит как баг: пользователь отменил команду, а она продолжает жить своей жизнью. Кооперативная отмена в Swift работает хорошо, но только если вы даёте ей шанс сработать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ