雲端 Mac 上驗收 iOS HTTP 重試:退避、限流與取消

雲端 Mac 上驗收 iOS HTTP 重試:退避、限流與取消

雲端 Mac 上驗收 iOS HTTP 重試:退避、限流與取消

API 回傳 429,用戶端卻立刻再送三次;使用者已經登出,舊請求仍在等待後重新送出。這類問題不是把逾時時間拉長就能解決。在雲端 Mac 上驗收 iOS 網路層,應先將重試拆成三個邊界:如何解讀回應、何時允許重送,以及取消後由誰阻止送出。測試不必真的等上幾十秒。

先定義允許重試的請求

從唯讀 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 圖形介面與命令列功能皆可使用。機型、節點與可用狀態,請以控制台即時顯示為準。

把這些步驟用在下一次封存。

先確認租用期間與規格,再開始這次建置。