地下鉄でユーザーがチケットを送信すると、画面にはネットワークエラーが表示されました。通信が回復して iOS アプリがリクエストを再送したところ、サーバーにはチケットが二件作成されていました。原因がキューにあるとは限りません。最初のリクエストは保存まで完了し、成功応答だけが端末に届かなかった可能性があります。オフライン書き込みを受け入れテストするには、「リクエストの失敗」と「書き込みの失敗」を区別する必要があります。
まず操作の識別情報を定義する
ユーザーによる一回の送信を、操作 ID、リクエスト本文、冪等キー、作成日時、状態を持つ永続化されたタスクとして保存します。本文はキューに入れる時点で確定させます。再試行で変更できるのは試行回数と状態だけで、冪等キーを作り直してはいけません。ユーザーが明示的にもう一度「新規作成」をタップした場合に限り、別の操作として扱います。
サーバー側では、冪等キーに紐づけて処理結果を保存する必要があります。同じキーと同じ本文なら元の結果を返し、同じキーで本文が異なる場合は、黙って上書きせず拒否します。クライアントがタスクを削除できるのは、明確な成功結果を受け取った後だけです。タイムアウト、接続断、サーバーエラーはいずれも結果が不明な状態であり、タスクを削除する根拠にはなりません。
受け入れ基準は「リクエストの送信が一回だけ」であることではありません。「同じユーザー操作を何度送信しても、業務レコードは一件しか作られない」ことです。
ローカルのテスト API で応答の欠落を再現する
以下のテスト API は、あるキーを初めて受け取ると、先にレコードを保存してから 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 シミュレータ内のアプリでは、テスト環境の API を http://127.0.0.1:8081/items に向けられます。このサーバーが待ち受けるのはローカルのループバックアドレスだけです。ポートを外部ネットワークに直接公開しないでください。テスト API のデータはプロセスのメモリ上にあるため、スクリプトを再起動するとレコードが消えます。したがって、アプリの再起動をテストするときにスクリプトまで再起動してはいけません。本番サービスでは冪等性に関する記録も永続化し、同時リクエストに対してはデータベースの一意制約で原子性を確保する必要があります。
四つの受け入れシナリオを層ごとに実行する
まず、リクエスト送信層に差し替え可能な入口を設けます。キューのユニットテストでは、送信層のテスト用実装に接続断エラーを返させ、タスクが永続ストレージに残り、本文とキーも変わっていないことを確認します。クラウド Mac 全体のネットワークを切断する必要はなく、同じマシンで動くほかの作業にも影響しません。
続いてローカル API に接続し、次の項目を一つずつ確認します。項目ごとに新しいキーを使ってください。テスト中はタスク ID、キー、送信回数、サーバーの返り値を記録しますが、リクエスト内の機密フィールドはログに残しません。
| シナリオ | 操作 | 合格条件 |
|---|---|---|
| 初回送信 | タスクを送信し、503 を受け取る | タスクが残り、キーが変わらない |
| 応答の欠落後に再試行 | 元のタスクを再送する | 200 が返り、record_count が 1 |
| アプリの再起動 | 最初の 503 の後にアプリを終了して再起動し、キューを復元する | 元のキーを使い続け、レコードは一件のまま |
| 本文が意図せず書き換わる | 同じキーで別の本文を送る | 409 が返り、タスクが要調査の状態になる |
シミュレータでの確認事項
最初に、アプリが実際にテスト API を使っていることを確認します。次に、クリーンな初期状態を作るため、テスト対象アプリ自身のデータだけを消去してください。共有シミュレータにあるほかのプロジェクトまで消してはいけません。初回送信後は、ローカルのタスクが残っているか確認します。アプリのプロセスを終了して再起動し、キューを復元させたうえで、二回目のリクエストに同じキーが付いているか観察します。キーをメモリ上にしか保持していないアプリなら、通常はここで問題が表面化します。
このスクリプトが数えるのは、単一プロセス内で受け取ったリクエストだけで、照会用 API もありません。そのため、二回目の応答に含まれる attempts と record_count で、そのテスト回の結果を検証します。テスト実行をまたいで集計する必要があるなら、テスト用の仕組みに永続ストレージを追加してください。プロセス内の数値を本番環境の指標として扱ってはいけません。
キューの再試行を止める条件を明確にする
すべてのステータスコードを同じように再試行してはいけません。接続断、タイムアウト、この例の 503 では、元のタスクを残して時間を置いて再試行できます。一方、409 は同じキーに対応する本文が衝突したことを示します。自動再送を止め、診断情報を残してください。タスクを完了状態にできるのは、明確な成功応答を受け取ったときだけです。実際の API では、ほかのクライアントエラーについても API の契約に照らして個別に判断し、一律に再試行しないようにします。
同時実行も制限します。同じタスクを送信する処理は、一度に一つだけにしてください。フォアグラウンド復帰とバックグラウンド同期の両方がキューを動かせるなら、まず永続ストレージ層でタスクの送信権を取得します。それでもサーバー側で独立して冪等性を保証する必要があります。クライアント側のロックでは応答の欠落を防げないためです。再試行間隔には上限付きのバックオフを使えば、通信回復時にすべてのタスクが一斉に流れ込むのを避けられます。ただし、バックオフによって操作 ID、キー、本文を変更してはいけません。
最終確認
マージ前に、もう一度手動で受け入れ確認をします。エラーを受け取った後にアプリを終了し、開き直して再送を待ちます。画面に表示されるレコードが一件だけであること、ローカルのタスクが最終的に完了状態になること、サーバーの結果に含まれる record_count が引き続き 1 であることを確認してください。その後、実際にもう一度「新規作成」をタップし、新しいキーと新しいレコードが作られることを確かめます。前者は既存の操作を安全に再送できる証拠であり、後者は冪等性のルールがユーザーの新たな操作を誤って妨げていない証拠です。
よくある質問
503 が返ったら新しい操作として再試行してもよいですか?
いいえ。保存済みの可能性があるため、元の操作と冪等キーを維持して再送します。キーを変えると重複登録の原因になります。
検証には Mac のネットワークを切る必要がありますか?
ありません。差し替え可能な通信層でキューへの登録を検証し、ローカルサーバーで応答消失後の HTTP 再送を確認できます。
次の実験には、専用のMacを
物理Mac mini、ノード、利用期間を必要に応じてお選びいただけます。モデルと在庫状況はコンソールでリアルタイムにご確認ください。