JavaRush /Курсы /Swift SELF /Retry и идемпотентность: что повторяем и почему

Retry и идемпотентность: что повторяем и почему

Swift SELF
66 уровень , 3 лекция
Открыта

1. Зачем нужен retry и чем он опасен

Retry обычно появляется в проекте так: вы один раз ловите сетевой сбой, второй раз, третий — и кто-то говорит сакральное «а давайте просто повторим запрос». На уровне эмоций идея понятная: интернет моргнул, Wi‑Fi чихнул, сервер задумался — повторили, и всё поехало дальше. Но в программировании есть традиция: как только идея «понятная и простая», она обязательно имеет два скрытых подвоха и одну ловушку в кустах.

Первый подвох — не все запросы можно безопасно повторять. Если повтор запроса может создать побочный эффект (например, дважды создать заказ, дважды списать деньги, дважды добавить книгу в удалённую коллекцию), то retry превращается в генератор дублей и пользовательского гнева. И это не «редкий случай», а нормальная реальность любого приложения, которое хоть что-то меняет.

Второй подвох — не все ошибки лечатся повтором. Если сервер вернул валидный ответ, но вы его не смогли распарсить (ошибка декодирования), повтор чаще всего вернёт тот же самый формат, а значит вы просто дважды (или десять раз) наступите на те же грабли, потратив время пользователя и батарейку его ноутбука.

Ловушка в кустах — бесконечный цикл. В нём легко оказаться, если «повторяем пока не получится», а «не получается» у вас — это, например, 401 Unauthorized. То есть вы сделали неправильный запрос, и теперь героически повторяете неправильный запрос до конца времён (или пока не сработает watchdog).

Чтобы retry был полезным, он должен отвечать на два вопроса:

  1. безопасно ли повторять по смыслу операции (идемпотентность);
  2. есть ли шанс, что повтор исправит конкретную категорию ошибки.

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
transport
«не удалось доставить запрос / нет связи / TLS / DNS / таймаут транспорта» часто да
invalidResponse
«ответ пришёл, но он не HTTP или странной формы» иногда да
httpStatus(code, ...)
«сервер ответил валидно, но статус-код говорит “нет”» зависит от кода
decoding
«ответ валидный, но мы не смогли распарсить 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 работает хорошо, но только если вы даёте ей шанс сработать.

1
Задача
Swift SELF, 66 уровень, 3 лекция
Недоступна
Метод и правило
Метод и правило
1
Задача
Swift SELF, 66 уровень, 3 лекция
Недоступна
Ошибка и повтор
Ошибка и повтор
1
Задача
Swift SELF, 66 уровень, 3 лекция
Недоступна
Ретрай обёртка
Ретрай обёртка
1
Задача
Swift SELF, 66 уровень, 3 лекция
Недоступна
Ретрай клиент
Ретрай клиент
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ