保存を一度だけ押したのに、一時エラーと自動リトライを経て、同じメモが二つ並びました。処理は最後に成功しています。二つ目はどこで作られたのでしょうか。

ここでは、リクエストごとにメモを作る小さなサービスを考えます。独立した JavaScript のサンプルで、書き込み後の応答を失わせ、同じサービスを呼ぶ二つのクライアントを比較します。

書き込みとエラーを同じ時系列に並べる

サンプルを開いたら、リトライループより先に通信を模した関数を読みます。メモサービスを呼び、返された ID を記録してから ResponseLost を投げています。クライアントにエラーが届く時点で、最初の書き込みは済んでいます。

段階 クライアントから見える状態 保存済みのメモ
最初の試行で書き込む 応答待ち 1 件
通信処理が応答を失う エラー 1 件
クライアントが再送する 再び応答待ち キーとサーバーの検索次第
2 回目の応答が届く 成功 1 件または 2 件

エラーは意図的に書き込みの後に置いています。呼び出し前に移すと別の条件を試すことになり、重複が見えなくなります。指定した回数だけ確実に例外を投げるため、実際のネットワークやタイマーは使いません。

ループに入る前に操作のキーを作る

二つのクライアントの違いは、キーを生成する場所だけです。

function saveUnsafe(send, title, newKey) {
  return createWithRetry(send, title, () => newKey());
}

function save(send, title, newKey) {
  const key = newKey();
  return createWithRetry(send, title, () => key);
}

saveUnsafe は、ループが送信するたびにコールバックで新しいキーを作ります。サービスは別々の操作として扱い、メモを二つ作ります。save は再試行前のキーを保持するため、両方の送信が同じ識別子とタイトルを使います。

サンプルのキーは比較しやすい連番文字列です。一度の保存意図を識別するもので、個々の通信試行やタイトルを識別するものではありません。同じタイトルを別々に保存すればキーも別になります。実際のコードでは生成器の寿命を確認してください。「冪等性キー」という名前でも、リトライ用のラッパー内で作り直されているかもしれません。

キーをサーバー側の検索まで追う

ここでキーの固定が有効なのは、サービスがメモを追加する前に completed を検索するからです。deduplicate オプションは既定で有効で、1件のテストだけがこの検索を無効にします。

if (deduplicate && completed.has(key)) {
  const previous = completed.get(key);
  if (previous.title !== title)
    throw new Error('key reused with another title');
  return previous.note;
}
const note = Object.freeze({ id: `note-${notes.length + 1}`, title });
notes.push(note);
completed.set(key, { title, note });
return note;

同じキーとタイトルなら、前に作ったメモを返します。同じキーでタイトルが変わると、追加書き込みの前に例外を投げます。このサンプルのリクエスト内容はタイトルだけなので、この比較で入力全体を確認しています。

保存するのは入力と結果の両方です。検索後の早期 return も確認します。そのまま追加処理に進めば、やはり重複します。修正版のテストでは件数に加えて返されたオブジェクトも調べます。別の再現テストではサーバーの重複排除を無効にし、クライアントのキーを固定しただけでは二つ作られることを確かめます。

失敗を実行し、保存された結果を比べる

retry-idempotency.mjs をダウンロードし、Node.js 22 以降で実行してください。依存パッケージのインストールは不要です。

node --test retry-idempotency.mjs

六つのテストがすべて通ります。「reproduce」を含む二つは、意図した不具合が起きると通ります。createWithRetry は合計 2 回まで送信し、模擬的な応答喪失だけを再試行します。タイトルの衝突は 1 回の試行で終了します。

条件 送信回数 保存件数 呼び出し元の結果
再試行時に新しいキー 2 2 2 件目の note-2
同じキーで結果を再利用 2 1 最初の note-1
独立した 2 回の保存 2 2 異なる二つの ID
既存キーでタイトルを変更 追加で 1 回 合計 1 件 エラー
両方の応答を失う 2 1 2 回目の試行後にエラー
同じキーで重複排除を無効化 2 2 2 件目のメモ

5 行目はエラー処理を読む際に役立ちます。クライアントが最後にエラーを受け取っても、メモは存在します。テストは保存内容、各試行のキー、返された ID を調べるため、成功応答だけを確認して重複を見落とすことを防げます。

実際のアプリで五つの場所を追う

  1. ユーザー操作を見つけ、どの試行が同じ操作意図に属するかを決めます。
  2. キーの生成箇所から、各リトライラッパーとリクエスト引数を追います。
  3. 実際の書き込みを開き、完了後に起こりうるエラーを記します。
  4. サーバーの検索、入力比較、以前の結果を返す分岐を追います。
  5. 試行回数の上限と最後のエラー経路を、保存状態と照らし合わせます。

対応するソース位置も残します。ラッパーがキー生成を含むコールバックを再実行するなら、その中まで読んでください。ルートからデータ層まで追う方法は、HTTP リクエストをコードで追跡するで扱っています。

メモリ上の例と永続化の保証を分ける

このサービスは、一つのプロセスの Map と配列に全データを保持します。検索も書き込みも同期処理です。データベースのトランザクション、複数 worker の並行実行、再起動、永続データからの復旧は試しておらず、本番での exactly-once を保証しません。

実装ではキーのスコープ、入力の比較、保持期間、同時試行の処理、書き込みと再利用用の記録を一緒に障害から守る方法を決める必要があります。また、このクライアントは保存処理を抜けるとキーを失います。後からの手動リトライや再読み込みには、操作の寿命を別途設計する必要があります。実際のストレージとクライアントを読み、コードを読むためのガイドを参考に関連する呼び出し元と定義を整理してください。