使用者在捷運上送出一張工單,畫面卻顯示網路錯誤。回到有網路的環境後,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、節點與計費週期。實際機型與供應狀況請以控制台即時資訊為準。