雲端 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 超過用戶端等待上限時,可以縮短嗎?
不可以。縮短後重送會違反伺服器要求。應回傳延後處理狀態,由上層保存工作或提示使用者,而非在本輪自動重試中提早發送。
如何避免退避測試真的等待?
注入目前時間、亂數與等待執行器。純函式只比對預定延遲;執行器測試使用可控制的暫停點,取消後確認發送次數沒有增加。