雲端 Mac 上驗收 iOS 離線寫入佇列的冪等重播

雲端 Mac 上驗收 iOS 離線寫入佇列的冪等重播

使用者在捷運上送出一張工單,畫面卻顯示網路錯誤。回到有網路的環境後,iOS App 重送請求,伺服器上卻出現兩張工單。問題不一定在佇列:第一次請求可能已經完成寫入,只是成功回應沒有送達手機。要驗收離線寫入,就必須區分「請求失敗」與「寫入失敗」。

先定義一次操作的身分

將使用者的一次提交存成一筆持久化任務,包含操作 ID、請求本文、冪等鍵、建立時間與狀態。本文在加入佇列時就要確定;重試只能改變嘗試次數與狀態,不能重新產生冪等鍵。只有使用者明確再次點選「新增」,才算另一筆操作。

伺服器需要依冪等鍵保存處理結果。鍵相同、本文相同時,應回傳原本的結果;鍵相同、本文不同時,應拒絕請求,而不是悄悄覆寫。用戶端只有收到明確的成功結果後,才能移除任務。逾時、斷線和伺服器錯誤都屬於結果未知,不能據此刪除任務。

驗收目標不是「請求只傳送一次」,而是「同一次使用者操作即使傳送多次,也只產生一筆業務紀錄」。

用本機介面模擬回應遺失

以下測試介面第一次收到某個鍵時,會先寫入紀錄,再回傳 503;再次收到相同的鍵時,則回傳已保存的結果。這是刻意模擬用戶端無法根據回應判斷寫入是否成功的情況。將程式碼存成雲端 Mac 上的 fault_server.py:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

records = {}
attempts = {}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/items":
            self.send_error(404)
            return
        key = self.headers.get("Idempotency-Key")
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length)
        if not key or not body:
            self.send_error(400)
            return
        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            self.send_error(400)
            return
        attempts[key] = attempts.get(key, 0) + 1
        if key in records and records[key]["body"] != payload:
            self.send_error(409)
            return
        if key not in records:
            records[key] = {"id": len(records) + 1, "body": payload}
            self.send_error(503)
            return
        result = json.dumps({
            "id": records[key]["id"],
            "attempts": attempts[key],
            "record_count": len(records)
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(result)))
        self.end_headers()
        self.wfile.write(result)

HTTPServer(("127.0.0.1", 8081), Handler).serve_forever()

執行 python3 fault_server.py。iOS 模擬器中的 App 可將測試環境介面指向 http://127.0.0.1:8081/items;這裡只監聽本機迴環位址,不要將連接埠直接開放給外部網路。測試介面使用行程記憶體,重新啟動腳本就會清空紀錄,因此重新啟動測試 App 時,不要重新啟動腳本。正式服務還需要將冪等紀錄持久化,並在並行請求下以資料庫的唯一性約束確保原子性。

分層執行四個驗收情境

先為請求傳送層保留一個可替換的入口。在佇列單元測試中,讓測試替身的傳送層回傳斷線錯誤:確認任務仍在持久化儲存空間中,而且本文與鍵都沒有改變。這樣不必切斷整台雲端 Mac 的網路,也不會影響同一台機器上執行的其他工作。

接著接上本機介面,依下表逐項檢查。每一項都使用新的鍵;測試期間記錄任務 ID、鍵、傳送次數與伺服器回傳值,但不要在日誌中寫入請求裡的敏感欄位。

情境 操作 通過條件
首次提交 傳送任務,收到 503 任務保留,鍵不變
回應遺失後重試 再次傳送原任務 回傳 200,record_count 為 1
App 重新啟動 首次收到 503 後結束並重新啟動 App,再恢復佇列 沿用原鍵,仍只有一筆紀錄
本文意外遭到改寫 相同的鍵改用另一份本文 回傳 409,任務進入待查狀態

模擬器檢查重點

先確認 App 實際使用的是測試介面,再清除測試 App 本身的資料,建立乾淨的測試基準;不要順手清掉共用模擬器裡其他專案的資料。首次提交後,檢查本機任務是否還在。結束 App 行程並重新啟動,觸發佇列恢復,觀察第二次請求是否帶有相同的鍵。如果 App 只把鍵存在記憶體中,這一步通常就會暴露問題。

這支腳本只統計單一行程內收到的請求,不提供查詢介面。因此,應以第二次回應中的 attempts 和 record_count 驗證該輪結果;若需要跨多次測試執行統計,就應為測試夾具另加持久化儲存空間,而不是把行程記憶體中的數字當成正式環境的指標。

為佇列設定明確的停止條件

重試時不應把所有狀態碼一視同仁。斷線、逾時和範例中的 503,可以保留原任務並延後重試;409 表示同一個鍵對應的本文發生衝突,應停止自動重送並留下診斷資訊。只有明確的成功回應,才允許將任務標記為完成。對於實際介面,其他用戶端錯誤也必須依介面契約逐項判斷,不能一律重試。

同時也要限制並行傳送:同一筆任務只能有一個傳送者。如果前景恢復與背景同步都能驅動佇列,應先在持久化層取得該任務的傳送權;伺服器仍須獨立確保冪等性,因為用戶端的鎖無法消除回應遺失。重試間隔可以採用有上限的退避機制,避免網路恢復時所有任務同時湧入,但退避不能改變操作 ID、鍵或本文。

最後檢查

合併前再進行一次人工驗收:收到錯誤後結束 App,重新開啟並等待重送;確認畫面上只出現一筆紀錄、本機任務最終變為完成,且伺服器結果中的 record_count 仍為 1。接著真正再次點選一次「新增」,確認它產生新的鍵與新的紀錄。前者證明舊操作可以安全重播,後者證明冪等規則不會誤擋使用者的新操作。

常見問題

收到 503 後,為何不能建立新操作再試一次?

原操作可能已經寫入。應保留原操作及其冪等鍵重送;改用新鍵可能產生第二筆紀錄。

測試離線佇列一定要中斷雲端 Mac 的網路嗎?

不用。透過可注入的傳輸層測試入列,再用本機介面驗證回應遺失後的實際 HTTP 重送即可。

為下次實驗準備一台專屬 Mac

依需求選擇實體 Mac mini、節點與計費週期。實際機型與供應狀況請以控制台即時資訊為準。

查看雲端 Mac 租用方案