クラウド Mac で iOS トークン更新の並行処理を検証する

クラウド Mac で iOS トークン更新の並行処理を検証する

一覧、アバター、メッセージ件数の取得リクエストが同時に送信され、古いトークンが無効になると、3 つのリクエストすべてが認証エラーになります。それぞれがトークンを更新すると、サーバー側でローテーションされるリフレッシュ用の認証情報が互いに上書きされるおそれがあります。その間にユーザーがログアウトすると、遅れて届いた応答がログイン状態を復元してしまうこともあります。クラウド Mac は、こうした競合状態を再現可能なテストにするのに適しています。本番 API には接続せず、偶然のネットワーク遅延にも頼らず、認証コーディネーターの動作だけを検証します。

まず 3 つの受け入れ条件を定義する

第一に、同じセッション内で同時に発生したトークン更新は 1 回だけ実行します。第二に、ログアウト後は、古い更新処理の結果で現在の認証情報を更新できないようにします。第三に、1 つの業務リクエストで許される更新と再試行は、最大 1 回です。認証失敗時にトークンを更新してよいかどうかは、API の仕様に基づいて判断します。権限に関するエラーをすべてトークンの期限切れとして扱ってはいけません。

コーディネーターには「そのリクエストが実際に使用したトークン」を渡します。現在のトークンがすでに別の値になっていれば、ほかのリクエストによって更新が完了したことを意味するため、再度更新せずに新しい値をそのまま使います。そうしないと、遅れて届いた失敗応答が、不要な 2 回目の更新を引き起こします。

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 を利用できます。後片付けの際にはタスクの識別子を比較し、古い呼び出しが実行を再開したときに、新しいタスクを誤って削除しないようにします。ここで使う非構造化 Task は、待機中の呼び出しの 1 つがキャンセルされても、自動ではキャンセルされません。これは意図的に維持している共有動作なので、キャンセルハンドラーを追加して終了させるべきではありません。

成功時は、まず共有状態を更新し、その後で呼び出し元のキャンセルを確認します。これにより、すでに閉じられた画面では結果の利用を止めつつ、待機を続けているリクエストは新しいトークンを取得できます。キャンセルの確認は待機終了後にのみ行うため、キャンセル時に即座に戻るインターフェースではありません。

クラウド 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" })
}

短い待機時間は、処理が重なる機会を増やすためのもので、性能指標ではありません。このテストには、「更新が完了した後で、古いトークンによる失敗を渡す」ケースも追加し、呼び出し回数が 1 回のままであることを検証する必要があります。これにより、待機中の呼び出しをまとめるだけで、遅れて届く応答への対応が抜けている実装を検出できます。

ログアウトとの競合テストは、固定時間のスリープに頼らないようにします。テスト側で制御できる continuation を使い、ダミー API から「更新が開始された」とテストに通知させます。その後、テストで logout を呼び出してから、結果を返せるようにします。ダミー API がキャンセルを無視して成功を返しても、以前から待機していた呼び出しは必ずエラーをスローしなければなりません。また、ログアウト後の新しい呼び出しは、直ちに失敗しなければなりません。

失敗、キャンセル、再試行を分けて扱う

共有された失敗を再試行の嵐にしない

更新処理がエラーをスローすると、すでに待機している呼び出しはすべて同じ失敗を受け取り、コーディネーターはタスクをクリアします。その後の新しい試行を許可するかどうかは、上位層の方針で決めます。一時的なネットワーク障害にはバックオフを設定でき、認証情報の無効化が明確な場合はセッションを終了するべきです。各待機側が catch 内で直ちに recover を呼び直すようにしてはいけません。

業務リクエストを再試行する前には、リクエストボディを再送できるかどうかも確認します。ファイルストリーム、一度しか読み出せないデータソース、書き込みを伴う操作では、リクエストの再構築や、サーバー側の冪等性を保証する仕組みとの連携が必要です。トークン更新の成功は、すべてのリクエストを安全に再送できることを意味しません。

ログアウトを状態の境界として扱う

タスクのキャンセルは協調的に行われます。通信層では処理がすでに完了している場合もあれば、キャンセルにすぐ反応しない場合もあります。そのため、ログアウト時には認証情報をクリアすると同時に、世代番号を増やす必要があります。本番環境での永続化も同じチェックで保護しなければなりません。通信を行うクロージャーが新しい認証情報を独自に書き込むと、コーディネーターによるログアウト時の隔離を迂回してしまいます。

競合を継続的な回帰テスト項目にする

コミット前に、少なくとも次のケースを網羅します。

記録するのはテスト名、更新回数、最終状態だけで十分です。トークンの内容は記録しません。クラウド Mac でこのテスト群を繰り返し実行する目的は、競合が絶対に起きないことを証明することではなく、競合が起きても結果がセッションの境界を守ることを確認することです。

よくある質問

画面を閉じたら共有の更新タスクもキャンセルしますか?

いいえ。他のリクエストが同じ更新を待っている場合があります。画面側の処理だけを中止し、ログアウト時に共有タスクをキャンセルしてセッション世代を進めます。

更新後の再試行でも認証に失敗したら、もう一度更新しますか?

更新しません。一つの業務リクエストにつき更新と再試行は一回までです。二度目は認証エラーを返し、セッション失効の判断はサーバーの仕様に従います。

開発作業に最適な専有クラウドMacを選ぶ

Apple Silicon搭載の物理ノードを、仮想マシンではなく専有でご利用いただけます。日・週・月・四半期単位でレンタルでき、機種と料金は公開しています。実際の空き状況はコンソールにリアルタイムで表示されます。

クラウドMacをレンタル 機種・レンタル期間を見る