Проверяем параллельное обновление токена iOS на облачном Mac

Проверяем параллельное обновление токена iOS на облачном Mac

Запросы списка, аватара и счётчика сообщений уходят к API одновременно. Когда старый токен теряет силу, все три запроса получают ошибку аутентификации. Если каждый из них начнёт обновлять токен самостоятельно, при ротации учётных данных для обновления результаты могут перезаписать друг друга. А если пользователь в этот момент выйдет из аккаунта, запоздалый ответ может восстановить состояние авторизации. На облачном Mac удобно превратить такую гонку в воспроизводимый тест: не обращаться к рабочему API, не рассчитывать на случайные задержки сети, а проверять только поведение координатора аутентификации.

Сначала задаём три проверяемых условия

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

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

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

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

Объединяем обновления с помощью общей Task

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

import Foundation

actor RefreshGate {
    enum Failure: Error { case signedOut }

    private var token: String?
    private var generation = 0
    private var flight: (id: UUID, task: Task<String, Error>)?
    private let refresh: @Sendable () async throws -> String

    init(token: String, refresh: @escaping @Sendable () async throws -> String) {
        self.token = token
        self.refresh = refresh
    }

    func recover(rejected: String) async throws -> String {
        guard let current = token else { throw Failure.signedOut }
        if current != rejected {
            try Task.checkCancellation()
            return current
        }

        let epoch = generation
        let active: (id: UUID, task: Task<String, Error>)
        if let existing = flight {
            active = existing
        } else {
            let operation = refresh
            active = (UUID(), Task { try await operation() })
            flight = active
        }

        do {
            let updated = try await active.task.value
            guard generation == epoch else { throw Failure.signedOut }
            if flight?.id == active.id {
                token = updated
                flight = nil
            }
            try Task.checkCancellation()
            return updated
        } catch {
            if flight?.id == active.id {
                flight = nil
            }
            throw error
        }
    }

    func logout() {
        generation += 1
        token = nil
        flight?.task.cancel()
        flight = nil
    }
}

Сначала сохраняем Task и только потом ждём результат — так другие вызовы смогут использовать ту же задачу. При очистке сравниваем идентификаторы задач, чтобы старый вызов после возобновления не удалил новую задачу по ошибке. Эта неструктурированная Task не отменяется автоматически при отмене одного из ожидающих вызовов. Такое совместное использование предусмотрено намеренно; не следует добавлять обработчик отмены, который будет останавливать общую задачу.

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

Запускаем локальный тест конкурентности на облачном Mac

Замените API обновления счётчиком, а в качестве тестовых учётных данных используйте только фиксированные фиктивные значения. Следующий тестовый метод нужно разместить в подклассе XCTestCase; достаточно, чтобы координатор и тест находились в одном тестовом таргете.

import XCTest

actor RefreshProbe {
    private(set) var calls = 0

    func run() async throws -> String {
        calls += 1
        try await Task.sleep(nanoseconds: 100_000_000)
        return "fresh-token"
    }
}

func testRefreshIsShared() async throws {
    let probe = RefreshProbe()
    let gate = RefreshGate(token: "old-token") {
        try await probe.run()
    }

    let values = try await withThrowingTaskGroup(of: String.self) { group in
        for _ in 0..<20 {
            group.addTask {
                try await gate.recover(rejected: "old-token")
            }
        }
        var output: [String] = []
        for try await value in group {
            output.append(value)
        }
        return output
    }

    let calls = await probe.calls
    XCTAssertEqual(calls, 1)
    XCTAssertEqual(values.count, 20)
    XCTAssertTrue(values.allSatisfy { $0 == "fresh-token" })
}

Короткая пауза увеличивает вероятность перекрытия вызовов во времени и не служит показателем производительности. Дополните этот тест сценарием, в котором ошибка со старым токеном поступает уже после завершения обновления, и проверьте, что число вызовов по-прежнему равно одному. Это выявит реализацию, которая объединяет ожидающие вызовы, но не учитывает запоздалые ответы.

При проверке гонки с выходом из аккаунта не полагайтесь на фиксированные задержки. Используйте управляемый continuation: имитация API сообщает тесту, что обновление началось, после чего тест вызывает logout и только затем разрешает вернуть результат. Даже если имитация API игнорирует отмену и возвращает успешный результат, старые ожидающие вызовы обязаны завершиться ошибкой. Новые вызовы после выхода должны сразу получать отказ.

Обрабатываем ошибки, отмену и повторы отдельно

Не превращаем общую ошибку в шквал повторных попыток

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

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

Выход из аккаунта задаёт границу состояния

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

Добавляем гонки в постоянный набор регрессионных тестов

Перед коммитом проверьте как минимум следующие сценарии:

Достаточно записывать название теста, число обновлений и итоговое состояние; содержимое токенов записывать не нужно. Повторяйте этот набор тестов на облачном Mac. Цель не в том, чтобы доказать невозможность гонки, а в том, чтобы убедиться: даже при её возникновении результат не нарушает границы сессии.

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

Нужно ли отменять общую задачу обновления при закрытии экрана?

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

Повторять ли обновление после второй ошибки аутентификации?

Нет. Для одного бизнес-запроса допускаются только одно обновление и один повтор. Вторая ошибка возвращается вызывающему коду; недействительность сессии определяется протоколом сервера.

Выберите выделенный облачный Mac для разработки

Физические узлы Apple Silicon, не виртуальные машины. Аренда на день, неделю, месяц или квартал. Конфигурации и цены опубликованы; актуальная доступность отображается в консоли.

Арендовать облачный Mac Конфигурации и сроки аренды