応答を失う決済APIを相手にする
目標
タイムアウトのあとのリトライが、どのように二重決済を生むかを自分で作ってみて、冪等キーで防ぎます。そして、キーを付けたのにうまくいかない場合まで確認します。
環境
/opt/app/pay.pyは、顧客企業の決済APIを真似たものです。直す対象ではなく、相手側です。
mkdir -p /root/idem
nohup python3 /opt/app/pay.py > /tmp/pay.log 2>&1 &
sleep 1
curl -s http://127.0.0.1:8021/health
POST /payments 결제 하나를 기록한다
Idempotency-Key 헤더가 있고 이미 본 키면
새로 기록하지 않고 저장해 둔 결과를 그대로 돌려준다
GET /payments 지금까지 기록된 결제 전부
POST /reset 상태 초기화
このコードブロックの韓国語は、各エンドポイントの説明です。POST /paymentsは決済を1件記録し、Idempotency-Keyヘッダーがあって、すでに見たキーなら、新しく記録せず、保存しておいた結果をそのまま返します。GET /paymentsは、これまでに記録されたすべての決済を返します。POST /resetは状態を初期化します。
偶数番目のリクエストは、処理を終えたあと、応答だけを6秒遅らせます。2秒でタイムアウトをかけると、クライアントは失敗と見なしますが、サーバーにはすでに記録されています。これが「不確実な結果」です。
作るもの
すべて/root/idem/の下です。
naive.sh 멱등키 없이. 타임아웃이면 다시 보낸다
naive.txt 그 결과와 왜 그런지
safe.sh 비즈니스 행위 하나에 키 하나. 재시도에도 같은 키
newkey.sh 시도마다 새 키를 만든다 (일부러 틀린 형태)
newkey.txt 그 결과와 키를 무엇 단위로 만들어야 하는지
backoff.sh 대기 시간 간격을 낸다
retryable.sh 상태를 받아 재시도 여부를 답한다
payments.csv 조사할 결제 기록
forensics.txt 중복을 찾고 원인을 지목한 결과
report.md 정리
このコードブロックの韓国語は、各ファイルの説明です。naive.shは冪等キーなしで、タイムアウトなら再送する、naive.txtはその結果と理由、safe.shはビジネス行為1つにキー1つで、リトライにも同じキー、newkey.shは試行ごとに新しいキーを作る(わざと間違った形)、newkey.txtはその結果と、キーを何の単位で作るべきか、backoff.shは待機時間の間隔を出す、retryable.shはステータスを受け取ってリトライの可否を答える、payments.csvは調査する決済記録、forensics.txtは重複を見つけて原因を指摘した結果、report.mdはまとめです。
採点方法
採点ツールが、毎回サーバーを初期化し、作成したスクリプトを直接実行したあと、記録を数えます。書いた数字ではなく、実際に残った記録を見ます。
naive.sh 기록 > 주문 이어야 한다 (중복이 생겨야 한다)
safe.sh 기록 = 주문 이어야 한다 (정확히 한 번)
newkey.sh 기록 > 주문 이어야 한다 (키가 무력해진다)
backoff.sh 간격이 늘고, 두 번 돌리면 달라야 한다
このコードブロックの韓国語は、naive.shは記録が注文より多くなる必要がある(重複が生じる必要がある)、safe.shは記録が注文と等しくなる必要がある(正確に1回)、newkey.shは記録が注文より多くなる必要がある(キーが無力になる)、backoff.shは間隔が増え、2回実行すると異なる必要がある、という意味です。
ステップ
- 決済APIを起動します。
naive.sh: 冪等キーなしで、注文を4件以上決済し、タイムアウトなら再送します。結果をnaive.txtに書きます。safe.sh: 注文ごとに1つのキーを決め、リトライするときに同じキーを再び使います。newkey.sh: キーを付けますが、試行ごとに新しく作ります。newkey.txtに、なぜ役に立たないかを書きます。backoff.sh: リトライの間隔を4つ以上出します。指数的に増え、ランダム性が混ざっている必要があります。retryable.sh: ステータス(500、429、400、timeoutなど)を引数として受け取り、yesまたはnoを出力します。payments.csvを作成し、重複を探します。重複した件の時刻の間隔が、原因を語ります。- まとめます。
参考
ステップ7の材料は、次のように作ります。
cat > /root/idem/payments.csv <<'CSV'
payment_id,order_id,created_at
PAY-1,ORD-1,2026-09-07T10:00:00Z
PAY-2,ORD-2,2026-09-07T10:00:05Z
PAY-3,ORD-2,2026-09-07T10:00:06Z
PAY-4,ORD-2,2026-09-07T10:00:08Z
PAY-5,ORD-2,2026-09-07T10:00:12Z
PAY-6,ORD-3,2026-09-07T10:01:00Z
PAY-7,ORD-4,2026-09-07T10:02:00Z
PAY-8,ORD-4,2026-09-07T10:02:01Z
PAY-9,ORD-4,2026-09-07T10:02:03Z
PAY-10,ORD-4,2026-09-07T10:02:07Z
CSV
間隔が1秒・2秒・4秒と倍になっているのが見えれば、ほぼ確定です。
応答を失うAPIを起動する
決済APIを起動します。
/opt/app/pay.pyをバックグラウンドで起動し、/healthが200かを見ます。開始前に/resetで状態を空にします。
タイムアウトを失敗と見なすと
naive.sh: 冪等キーなしで、注文を4件以上決済し、タイムアウトなら再送します。結果をnaive.txtに書きます。
curl --max-time 2でタイムアウトをかけ、失敗したら||でもう一度送ります。注文4件以上。そのあと、GET /paymentsで実際の記録を数えてみてください。
冪等キーで正確に1回
safe.sh: 注文ごとに1つのキーを決め、リトライするときに同じキーを再び使います。
注文ごとにキーを1つ決め、リトライにも同じキーを使います。-H "Idempotency-Key: $K"を、両方の呼び出しに付けてください。
キーを付けたのにうまくいかない場合
newkey.sh: キーを付けますが、試行ごとに新しく作ります。newkey.txtに、なぜ役に立たないかを書きます。
試行ごとに新しいキーを作ってみてください。サーバーは別のリクエストと見なして、また記録します。キーは、「HTTPリクエスト1つ」ではなく「ビジネス行為1つ」に対して作ります。
間隔を広げて、ばらつかせる
backoff.sh: リトライの間隔を4つ以上出します。指数的に増え、ランダム性が混ざっている必要があります。
間隔が毎回大きくなる必要があり(指数バックオフ)、2回実行すると値が違う必要があります(ジッター)。ジッターがないと、すべてのクライアントが同じ時刻に同時にリトライします。
何をリトライし、何をリトライしないか
retryable.sh: ステータス(500、429、400、timeoutなど)を引数として受け取り、yesまたはnoを出力します。
リクエストが誤っているもの(400・422)と、認証情報の問題(401・403)は、何回送っても同じです。429はリトライしますが、Retry-Afterを守ります。
間隔が原因を語る
payments.csvを作成し、重複を探します。重複した件の時刻の間隔が、原因を語ります。
重複した注文を探し、その記録の時刻の差を見てください。1秒・2秒・4秒のように倍に増えるなら、クライアントのリトライです。
まとめ
まとめます。
タイムアウトが何を意味するか、キーを何の単位で作るか、バックオフとジッターがそれぞれ何を防ぐかを、書いてください。