雲端 Mac 驗證 iOS 權杖更新競態與登出隔離

雲端 Mac 驗證 iOS 權杖更新競態與登出隔離

雲端 Mac 驗證 iOS 權杖更新競態與登出隔離

列表、大頭貼和訊息計數同時向 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 忽略取消並回傳成功,舊等待者也必須拋出錯誤;登出後的新呼叫則必須直接失敗。

分別處理失敗、取消與重試

別讓共用失敗引發重試風暴

更新權杖時若拋出錯誤,現有等待者會收到同一次失敗,協調器則會清除任務。後續是否允許再次嘗試,由上層策略決定:暫時性的網路失敗可以採用退避策略;憑證明確失效時,應結束工作階段。不要讓每個等待者在 catch 中立即再次呼叫 recover。

重試業務請求前,還要確認請求本文能夠重新傳送。檔案串流、一次性資料來源,以及會產生寫入的操作,都需要重建請求,或配合伺服器的冪等協定。權杖更新成功,不代表所有請求都能安全地重新送出。

登出是一道狀態邊界

任務取消採協作式機制,傳輸層可能已經完成,也可能未及時回應取消。因此,登出時必須同時清除憑證並遞增世代。正式環境中的持久化儲存也必須受相同檢查保護,不能讓傳輸閉包自行寫入新憑證,否則會繞過協調器的登出隔離。

將競態納入固定回歸測試

提交前至少涵蓋以下情況:

只需記錄測試名稱、更新次數與最終狀態,不記錄權杖內容。在雲端 Mac 上反覆執行這組測試,重點不是證明競態永遠不會發生,而是確認競態發生時,結果仍符合工作階段的邊界。

常見問題

關閉畫面時,要取消共用的更新工作嗎?

不要。其他請求可能還在等待同一次更新。畫面只取消自己的後續處理;登出時才取消共用工作,並遞增工作階段世代,阻止舊結果寫回。

更新後重試仍驗證失敗,可以再更新嗎?

同一個業務請求最多更新並重試一次。第二次失敗應回傳驗證錯誤,工作階段是否失效依伺服器協定判斷,不可遞迴更新形成無限迴圈。

為開發工作租用專屬雲端 Mac

採用 Apple Silicon 實體主機,並非虛擬機。提供日租、週租、月租及季租,機型與價格公開透明;實際可租狀態請以控制台即時資訊為準。

立即租用雲端 Mac 查看機型與租期