TT Lab
はじめる
学ぶ 学習パス コース

銀行現場の言葉

同じ送金が二度出た — 止める側を作る

TT Labで続きを見る

目標

クライアントのリトライで同じ送金リクエストが2回入ってきても、お金が1回しか出ないようにする層を、自分で作ります。冪等キーのテーブルとUNIQUE制約、リクエスト本文の正規化フィンガープリント、処理中の状態、レスポンスの再生、キーの範囲と保管期間まで加え、1日分のリクエストログを流し直して、二重送金が0であることを証明します。

なぜ重要なのか

レスポンスを受け取れなかったクライアントは、もう一度送ります。これを防ぐ方法はなく、防いでもいけません。RFC 9110はPOSTを冪等とみなしていないので、プロトコルが与える保証はありません。保証はアプリケーションが作ります。 作り方の骨格は、IETFドラフト(draft-ietf-httpapi-idempotency-key-header)がまとめています。キーはクライアントが作り、フィンガープリントはサーバーが作り、完了したキーのリトライには保存したレスポンスを再生し、処理中のキーのリトライには競合で答えます。 このラボで難しいのはコードではなく、境界です。照会してから挿入すると同時リトライを見逃し、キーを完了として先に書くと、死んだときにお金が永遠に出ず、フィンガープリントを見ないと、金額が変わったリクエストが黙って無視されます。 採点ツールは、書かれた文言を信用しません。一時ディレクトリに採点ツールが作った口座データベースを用意し、毎回異なる口座・金額・キーでスクリプトを実際に実行して、レスポンスJSON・送金テーブル・残高を、自分で測った値と突き合わせます。

ステップ

  1. /root/idem/gen_requests.pyを作成して実行し、/root/idem/idem.dbを作ってください。口座8個、リクエスト120件(異なるキー100個)、その日の送金120件が入ります。
  2. リトライが生んだ二重送金を集計して、/root/idem/dup_report.jsonにrequests・unique_keys・duplicate_keys・extra_transfers・double_paid・keysを書いてください。
  3. /root/idem/idem_api.pyを作成し、冪等キーのテーブルのUNIQUE制約によって、同じキーの2回目のリクエストが送金を作らないようにしてください。キーが違えば止めません。
  4. idem_api.pyがリクエスト本文の正規化フィンガープリントを保存し、同じキーで本文が違えば422で拒否するようにしてください。フィールドの順序だけが違う同じ本文は、同じリクエストです。
  5. idem_api.pyのキー記録を、先取り(in_progress)と完了(completed)の2段階に分け、処理中のキーのリトライには409 in_progressで答えるようにしてください。--crash-after-claimで、処理の前に死ぬ状況を作れる必要があります。
  6. 完了したキーのリトライに、保存しておいたレスポンスをそのまま再生するようにしてください。statusは最初のレスポンスと同じで、replayはtrueであり、本文は1文字も違ってはいけません。
  7. キーの範囲を(client_id, endpoint, idem_key)に広げ、--purge-beforeで保管期間が過ぎたキーを削除してください。ポリシーは/root/idem/policy.jsonに書きます。
  8. /root/idem/replay_day.pyでリクエスト120件を1件も漏らさずに流し直して、/root/idem/day.dbと/root/idem/result.jsonを作り、/root/idem/idem_report.mdに4つの節で報告してください。

参考

その日のリクエストログを作る

/root/idem/gen_requests.pyを作成して実行し、/root/idem/idem.dbを作ってください。口座8個、リクエスト120件(異なるキー100個)、その日の送金120件が入ります。

まず/root/idemを作成し、その中でpython3を使ってsqliteのDBを作ります。テーブルはaccount、req_log、transfer_v1の3つです。req_logはゲートウェイが受け取ったリクエストそのままなので、リトライも1行ずつ入っており、transfer_v1はそのリクエストが実際に作った送金です。

リトライが生んだ二重送金を数える

/root/idem/dup_report.jsonにrequests・unique_keys・duplicate_keys・extra_transfers・double_paid・keysを書いてください。duplicate_keysは送金が2件以上できたキーの個数で、keysはそのキーの一覧です。

extra_transfersは、キーごとに最初の送金を除いた残りの件数です。double_paidは、その残りの送金の金額の合計です。SQLite 3.25から使えるROW_NUMBER() OVER (PARTITION BY … ORDER BY …)で、キーの中で何番目の送金かを採番すれば、一度に出ます。

同じキーの2回目のリクエストを止める

/root/idem/idem_api.pyを作成し、冪等キーのテーブルのUNIQUE制約によって、同じキーの2回目のリクエストが送金を作らないようにしてください。キーが違えば、同じ本文でも止めません。

照会してなければ入れる方式では、同じ瞬間に入ってきた2件が、どちらも通ってしまいます。キーを主キーにしたテーブルにいきなりINSERTし、制約違反の例外(sqlite3.IntegrityError)を「すでにある」というシグナルとして使ってください。2回目のレスポンスは、replayをtrueにするか、409で答えればかまいません。

同じキーなのに本文が違えば拒否する

idem_api.pyがリクエスト本文の正規化フィンガープリントをキーと一緒に保存し、同じキーで本文が違えば422で拒否するようにしてください。フィールドの順序だけが違う同じ本文は、同じリクエストとして扱う必要があります。

フィンガープリントを元の文字列で計算すると、フィールドの順序や空白が違うだけで別のリクエストになります。RFC 8785が規定する正規化の核心は、キーの並べ替えと空白の除去です。Pythonではjson.dumpsのsort_keysとseparatorsで近似でき、そのバイト列をsha256に入れます。

処理中のキーと同時リトライ

キーの記録を先取り(in_progress)と完了(completed)の2段階に分け、処理中のキーのリトライには409とreason in_progressで答えるようにしてください。--crash-after-claimは、先取りだけをして、送金なしで0以外のコードで終了する必要があります。

キーを完了として先に書くと、送金の途中で死んだとき、お金は出ていないのに、リトライは「処理済み」を受け取ります。先取りのトランザクションと実行のトランザクションを分けておけば、途中の状態がテーブルに残ります。採点ツールは、同じ瞬間に入ってくるリトライ2件も送ります。2件のうち新しく実行されるのは、ちょうど1件でなければなりません。

保存したレスポンスをそのまま再生する

完了したキーのリトライに、保存しておいたレスポンスをそのまま返すようにしてください。statusは最初のレスポンスと同じで、replayはtrueであり、bodyは最初のレスポンスと1文字も違ってはいけません。

再生は再計算ではありません。レスポンス本文を完了の時点で丸ごと保存しておき、そのまま取り出して使います。再計算すると、残高や時刻が変わり、顧客の画面が2回違って見えます。処理中のキーは、引き続き409でなければなりません。

キーの範囲と保管期間を決める

キーの範囲を(client_id, endpoint, idem_key)に広げ、--purge-before <RFC 3339 시각>がそれより古いキーを削除して{"purged": 개수}を出力するようにしてください(プレースホルダーは、順にRFC 3339形式の時刻と件数です)。ポリシーは/root/idem/policy.jsonにscope・retention_hours(72)・on_fingerprint_mismatch(422)・on_in_flight(409)で書きます。

キーがグローバルだと、別の顧客がたまたま同じ文字列を送ったときに、他人のレスポンスを受け取ります。主キーを3つの列に変えると、範囲ができます。保管期間が過ぎてキーを消したあと、同じリクエストは新しいリクエストです。そのため、保管期間はクライアントのリトライ上限より長くなければなりません。

1日分を流し直して証明する

/root/idem/replay_day.pyでリクエスト120件を1件も漏らさずに送り直して、/root/idem/day.dbと/root/idem/result.jsonを作り、/root/idem/idem_report.mdに## 무엇이 잘못됐나、## 어떻게 막았나、## 남은 위험、## 운영 규칙の4つの節で書いてください(見出しは韓国語で、順に「何が問題だったか」「どう防いだか」「残るリスク」「運用ルール」という意味です)。

リクエストを選り分けると、証明になりません。req_logをseq順にすべて送り、結果をrequests・created・replayed・rejected・transfers・total_transferred・v1_total・double_paid_avoidedで数えます。idem_api.pyをモジュールとして読み込めば、プロセスを120回起動しなくても済みます。レポートには、今回防いだ金額を数字で書いてください。