応答を受け取れなかったクライアントは、もう一度送る
一言でいうと
冪等キーは、クライアントが付けて送るリクエストのラベルです。サーバーはそのラベルで「このリクエストはすでに処理した」ことを記憶し、2回目の実行を防いで、1回目のレスポンスをそのまま返します。
なぜ必要なのか
送金APIにリクエストを送ったクライアントが、レスポンスを受け取れないことは珍しくありません。ゲートウェイのタイムアウトがサーバーの処理より短かったり、ロードバランサーが接続を切ったり、スマートフォンが地下に入ったりしたのかもしれません。このときクライアントが知っている事実は、1つだけです。レスポンスを受け取れなかった。お金が出たのか出ていないのかは、わかりません。
ここでクライアントに残る選択肢は2つです。諦めるか、もう一度送るか。諦めれば、ユーザーは「送金できなかった」と思って、もう一度押します。結局、リクエストはどのみちもう1回届きます。したがって、リトライ自体をなくすことは答えではなく、リトライを安全にすることが答えです。
RFC 9110の9.2.2節は、この性質をメソッド単位で定義しています。同じリクエストを何回送っても、サーバーに対する意図した効果が1回送ったときと同じなら、そのメソッドは冪等(idempotent)で、PUT・DELETEと安全なメソッドがここに含まれます。同じ節は、冪等なメソッドが区別される理由を、レスポンスを読む前に通信が切れたときに自動でリトライできるからだと書き、冪等でないメソッドは、クライアントが勝手に自動リトライしてはならないと明記しています。送金はPOSTです。つまり、プロトコルが与える保証はありません。保証は私たちが作らなければなりません。
どう動くのか
作り方は、ずっと前に結論が出ています。クライアントがリクエストごとに一意のキーを1つ付け、サーバーはそのキーを保存します。この慣行を文書にまとめたのが、IETFドラフトdraft-ietf-httpapi-idempotency-key-headerです。ドラフトは標準ではありません。RFC番号がなく、内容も変わりえます。それでも、今この問題を扱う最も整理された文書なので、実務の共通言語の役割を果たしています。
ドラフトが定める骨格は、4つです。
- キーはクライアントが作ります。一意でなければならず、本文が異なるリクエストに同じキーを再利用してはいけません。UUIDのような任意の値が推奨されています。
- フィンガープリント(fingerprint)はサーバーが作ります。リクエスト本文から計算した検査値で、ドラフトは、本文全体のチェックサム・一部のフィールドのチェックサム・フィールドごとの値の比較といった方式を例に挙げています。キーだけを見ていては、「同じキーで別の金額を送る」事故を防げません。
- 最初のリクエストは通常どおり処理します。その結果とステータスコードをキーに付けて保存します。
- 重複リクエストは2つに分かれます。前のリクエストが終わったあとに来たリトライには、保存してある結果をそのまま返し、前のリクエストがまだ処理中のときに来たリトライには、競合エラーで答えます。
エラーコードもドラフトが提案しています。同じキーに別の本文が来たら422(Unprocessable Content)、前のリクエストがまだ処理中なら409(Conflict)です。2つの違いは、クライアントがすべきことが違う点にあります。422はリクエストを直す必要があり、409は直すものはなく、しばらくしてからもう一度問い合わせればよいのです。
ストレージ側では、この設計の核心は1行です。キーのテーブルにUNIQUE制約をかけ、2回目のINSERTが失敗すること自体を判定に使います。「先に照会して、なければ入れる」は、照会と挿入のあいだに隙間があるため、同じ瞬間に入ってきたリトライ2件が、どちらも「ない」を見て、どちらも実行してしまいます。SQLiteのON CONFLICT句は、制約に違反したときの動作をROLLBACK・ABORT・FAIL・IGNORE・REPLACEの5つから選ぶもので、デフォルトはABORTです。ここで注意すべきなのはINSERT OR IGNOREです。衝突した行を黙ってスキップするので、リトライなのか新しいリクエストなのかを、コードが判別できなくなります。私たちに必要なのは例外です。Pythonではsqlite3のIntegrityErrorとして上がってきます。
요청 + Idempotency-Key
│
├─ 키 선점 INSERT 성공 → 이체 실행 → 응답 저장(completed) → 201
└─ UNIQUE 위반 → 지문 다름 → 422
처리 중 → 409
완료됨 → 저장한 응답을 그대로 재생
このコードブロックの韓国語コメントは、リクエストとIdempotency-Keyを受けたら、キーを先取りするINSERTを試み、成功すれば送金を実行してレスポンスを保存(completed)し201を返し、UNIQUE違反ならフィンガープリントが違えば422、処理中なら409、完了済みなら保存したレスポンスをそのまま再生する、という流れを述べています。
現場での姿
第一に、フィンガープリントを見ない実装が最も多いです。キーだけを見て「すでにあるので成功」と答えると、窓口の職員が金額を直して同じ画面からもう一度送ったリクエストが、黙って無視されます。顧客は30万ウォンを送ったつもりですが、実際に出たのは20万ウォンです。この事故は、ログにもエラーとして残りません。
第二に、本文の比較を文字列で行ってしまうことです。クライアントライブラリがJSONのフィールド順や空白を変えると、同じリクエストが別のフィンガープリントになり、422が大量に返ります。そのため、フィンガープリントは正規化したあとで計算します。RFC 8785(JSON Canonicalization Scheme)がこの正規化を規定しています。オブジェクトのキーをコードポイント順に並べ替え、空白をなくし、数値と文字列の表記を1通りに固定したうえで、UTF-8でシリアライズします。Pythonではjson.dumps(obj, sort_keys=True, separators=(",", ":"))が実務で使える近似です(RFC 8785の数値表記ルールまではそのまま満たしません)。
第三に、処理中の状態がないことです。キーを「完了」として先に書き込んでから送金を実行すると、実行の途中でプロセスが死んだとき、キーだけが残ります。その後のリトライは「処理済み」という答えを受け取り、お金は永遠に出ません。逆に、送金を先にしてキーをあとで書くと、重複が起きます。そのため、先取り(in_progress)と完了(completed)を分けて、2段階で記録します。
第四に、キーの範囲を決めていないことです。キーがグローバルだと、別の顧客がたまたま同じ文字列を送ったときに、他人のレスポンスを受け取ります。範囲は通常、(顧客、エンドポイント、キー)の3つで決めます。保管期間もあわせて決める必要があります。上のドラフトも、有効期限のポリシーを文書で公開するよう書いています。期間が過ぎてキーを消すと、その後のリトライは新しいリクエストになるので、保管期間はクライアントのリトライ上限より余裕がなければなりません。
実務で本当に大切なこと
- リトライは防げません。安全にできるだけです。設計の出発点はここに置きます。
- キーの再利用は、黙って成功させず、422で拒否します。何が本当のリクエストなのか、サーバーにはわかりません。
- レスポンスは再計算せず、保存したものをそのまま再生します。再計算すると、残高や時刻が変わり、顧客の画面が2回違って見えます。
- キーは送金番号ではありません。キーを消したあとに同じリクエストが来れば、それは新しい送金です。保管期間とリトライ上限をあわせて、文書に書きます。
次のラボですること
その日のリクエストログを自分で作り、冪等の保護がなかったシステムが生んだ二重送金を、まず集計します。そのあと、冪等キーのテーブルとUNIQUE制約、正規化フィンガープリント、処理中の状態、レスポンスの再生、キーの範囲と保管期間を順に加えます。採点ツールは、毎回異なる口座・金額・キーで、作成したエンドポイントを実際に実行してレスポンスと残高を突き合わせ、同じ瞬間に入ってくるリトライ2件も送ります。最後に、1日分のリクエストを1件も漏らさず流し直して、二重送金が0になることを証明します。