Проверка HTTP-повторов iOS: задержки, лимиты и отмена

Проверка HTTP-повторов iOS: задержки, лимиты и отмена

Проверка HTTP-повторов iOS: задержки, лимиты и отмена

API возвращает 429, а клиент тут же отправляет ещё три запроса. Пользователь уже вышел из аккаунта, но старый запрос после ожидания отправляется снова. Увеличение тайм-аута такие ошибки не исправит. При приёмке сетевого слоя iOS на облачном Mac сначала стоит разделить логику повторов на три зоны ответственности: как интерпретируется ответ, когда разрешена повторная отправка и кто не допускает отправку после отмены. Для проверки не нужно действительно ждать десятки секунд.

Сначала определите, какие запросы можно повторять

Начните с GET-запросов, которые только читают данные. В примере обрабатываются лишь коды 429 и 503. Для остальных статусов и транспортных ошибок нужны отдельные стратегии: не стоит сводить все сбои к одному циклу. Первая отправка не считается повтором. Максимум три повтора — это максимум четыре отправки.

Для запросов, изменяющих данные, нельзя определять безопасность повторной отправки только по имени метода. Автоматический повтор допустимо рассматривать лишь тогда, когда сервер поддерживает протокол идемпотентности, одна и та же бизнес-операция использует один и тот же ключ идемпотентности, а тело запроса можно прочитать заново. Для потоковой загрузки также нужно проверить, можно ли создать поток повторно.

Тайм-аут означает лишь то, что клиент не получил результат. Он не означает, что сервер не завершил запись. Отмена локальной задачи тоже не отменяет операцию, которая уже дошла до сервера.

Лимит повторов нужно хранить отдельно для каждого логического запроса. Не обнуляйте счётчик при каждом входе в сетевую функцию: если внешний и внутренний уровни выполняют по три повтора, фактическое число запросов многократно возрастает.

Преобразуйте заголовок ответа в чёткое ограничение на ожидание

У Retry-After есть два вида значений: неотрицательное целое число секунд и HTTP-дата. Для целых чисел допускаются только ASCII-цифры; отрицательные и дробные значения, а также переполнение отклоняются. При разборе даты нужно явно задать английскую локаль и GMT, не полагаясь на текущий язык узла. Ниже показан разбор распространённого формата IMF-fixdate:

import Foundation

func serverDelay(_ raw: String?, now: Date) -> TimeInterval? {
    guard let raw else { return nil }
    let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)

    if !text.isEmpty,
       text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) {
        guard let seconds = UInt64(text) else { return nil }
        return TimeInterval(seconds)
    }

    let parser = DateFormatter()
    parser.locale = Locale(identifier: "en_US_POSIX")
    parser.timeZone = TimeZone(secondsFromGMT: 0)
    parser.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
    parser.isLenient = false

    guard let date = parser.date(from: text),
          parser.string(from: date) == text else {
        return nil
    }
    return max(0, date.timeIntervalSince(now))
}

Это строгий парсер распространённого формата, а не полная реализация поддержки исторических форматов HTTP-даты. Если контракт API требует принимать устаревшие форматы, для них нужны отдельные ветви разбора и тестовые фикстуры. При некорректном значении в этом примере используется локальная задержка повторов, а причина ошибки разбора записывается в журнал.

Зафиксируйте now и отдельно проверьте даты в прошлом, в настоящем и в будущем. Для самого ожидания используйте монотонные часы. Календарное время нужно только для преобразования даты в оставшееся число секунд: тогда коррекция системного времени не изменит уже начавшееся ожидание.

Вычисляйте задержку чистой функцией, не выполняя ожидание внутри неё

Сохраните следующий код в retry-check.swift, поместив его после приведённой выше функции разбора. retryIndex начинается с нуля; базовая задержка последовательно составляет 1, 2 и 4 секунды. К ней применяется пропорциональный джиттер, а случайное значение передаёт вызывающий код.

enum Decision: Equatable {
    case stop
    case deferred
    case retry(TimeInterval)
}

func decide(
    status: Int,
    replayable: Bool,
    retryIndex: Int,
    server: TimeInterval?,
    unit: Double
) -> Decision {
    guard replayable,
          [429, 503].contains(status),
          (0..<3).contains(retryIndex),
          unit.isFinite,
          (0...1).contains(unit) else {
        return .stop
    }

    let base = pow(2.0, Double(retryIndex))
    let local = base * (0.5 + 0.5 * unit)

    if let server, !server.isFinite || server < 0 {
        return .stop
    }

    let delay = max(local, server ?? 0)
    guard delay <= 30 else { return .deferred }
    return .retry(delay)
}

precondition(decide(
    status: 503, replayable: true,
    retryIndex: 0, server: nil, unit: 0
) == .retry(0.5))

precondition(decide(
    status: 429, replayable: true,
    retryIndex: 1, server: 10, unit: 1
) == .retry(10))

precondition(decide(
    status: 429, replayable: true,
    retryIndex: 0, server: 60, unit: 0
) == .deferred)

precondition(decide(
    status: 503, replayable: true,
    retryIndex: 3, server: nil, unit: 1
) == .stop)

precondition(decide(
    status: 503, replayable: false,
    retryIndex: 0, server: nil, unit: 1
) == .stop)

Запустите файл на узле с настроенным инструментарием Swift:

swift retry-check.swift

Штатное завершение означает, что проверки пройдены. Не добавляйте параметры оптимизации, отключающие проверки. Предел в 30 секунд здесь выбран для примера автоматического ожидания, а не задан протоколом. Если сервер требует ждать дольше, функция возвращает состояние отложенной обработки. Нельзя сокращать задержку до 30 секунд и отправлять запрос раньше разрешённого срока.

Проверяйте границы отмены, а не только значения задержек

Исполнитель получает только решение, после чего вызывает внедряемую функцию ожидания. В рабочей среде можно использовать поддерживающий отмену Task.sleep. В тестах нужна управляемая точка приостановки с уведомлением о том, что задача уже перешла к ожиданию. Это избавляет от попыток угадать состояние задачи с помощью коротких пауз.

Проверьте как минимум три последовательности событий:

Поколение сеанса — это идентификатор, который увеличивается при каждом изменении состояния входа в аккаунт. Даже если старый запрос получил успешный ответ, перед обновлением интерфейса или кэша необходимо сравнить поколения. Между проверкой отмены и фактической отправкой запроса всё равно остаётся гонка. Поэтому нельзя утверждать, что отмена гарантированно исключает получение запроса сервером.

Превратите обнаруженные сбои в критерии проверки перед слиянием

Дополните тесты парсера случаями с отсутствующим значением, пробельными символами, отрицательными и дробными числами, слишком большими целыми и граничными датами. Для планировщика проверьте индексы повторов от нуля до трёх, крайние случайные значения ноль и один, а также серверную задержку, точно равную верхнему пределу и превышающую его.

В интеграционных тестах используйте сценарный транспортный дублёр, который последовательно возвращает 503, 429 и 200. Перед каждой отправкой сверяйте запланированную задержку. Добавьте сценарии отмены, тела запроса, которое нельзя прочитать повторно, и исчерпания лимита повторов. Не вызывайте ограничение частоты запросов на реальных публичных API и не заставляйте тестовую среду действительно ждать столько, сколько указано в заголовке ответа.

В журнале оставляйте только идентификатор логического запроса, код статуса, индекс повтора, запланированную задержку и причину завершения. Не записывайте заголовки аутентификации и тело запроса. Итоговая приёмка сводится к четырём условиям: нет преждевременных повторных отправок, нет отправок сверх лимита, отмена не трактуется как обычная ошибка, а запросы, которые нельзя отправить повторно, не попадают в цикл. Только после этого стоит обсуждать настройку параметров задержки.

Часто задаваемые вопросы

Нужно ли повторять каждый запрос с ответом 429 или 503?

Нет. Сначала проверьте безопасность повторной отправки, остаток лимита и требуемую сервером задержку. Для записи нужны поддерживаемый сервером механизм идемпотентности и повторно отправляемое тело.

Можно ли сократить Retry-After до своего предела ожидания?

Нет: запрос уйдёт раньше разрешённого срока. Верните результат отложенной обработки, чтобы вызывающий слой сохранил задание или уведомил пользователя.

Как проверять задержки без реального ожидания?

Передавайте часы, случайное значение и механизм ожидания как зависимости. Сравнивайте рассчитанные задержки напрямую, а исполнение отменяйте в управляемой точке приостановки. Число отправок после этого не должно расти.

Нужен выделенный Mac для сборки?

Выберите срок аренды под свои задачи. Весь физический Mac — только в вашем распоряжении: графический интерфейс macOS и командная строка полностью доступны. Модель, регион и наличие уточняйте в консоли — там отображаются актуальные данные.

Примените эти шаги при следующей сборке.

Сначала проверьте срок аренды и конфигурацию, затем запускайте сборку.