Claude APIの設計と運用

Claudeの操作を再試行するとき:二重実行を防ぐ状態管理

返事が来なかったことは、相手が処理していない証拠ではありません。モデルが同じ操作を再度要求したり、アプリがタイムアウトを再送したりすると、通知や登録が重複する可能性があります。再試行の前に、操作の識別と状態を設計します。

編集・運営:株式会社ノースネスト · AI支援の学習解説

公開: · 更新: · 約4分(目安)

この記事の目的

通信の失敗と業務操作の失敗を区別し、再試行による重複を防げる。

こんな方へ

ツール経由で登録や更新を実行するアプリの開発者。

一つの業務要求に一つの操作IDを付ける

再試行ごとに新しいIDを作ると、相手から見れば毎回別の操作になります。同じ意図の再送は同じキーで識別し、入力内容も照合します。同じキーで違う内容が来た場合は、過去の結果を無条件に返すのではなく不整合として扱います。

操作IDは、モデルが文章の中で覚えていることに依存させず、アプリが保持します。キーを付けただけでは重複を防げないため、保存先や実行先でその契約を実装する必要があります。

不明な状態を持てるようにする

未開始、処理中、成功、失敗に加え、結果確認が必要な状態を考えます。通信が切れた場合は、既存の操作IDで実行先へ結果を照会できると復旧しやすくなります。確認できない状態を失敗と決めつけて再送しないことが重要です。

外部サービスが重複防止を提供しない場合は、操作の範囲を見直す、手動確認へ回すなど別の手段が必要です。画面のボタンを無効にするだけでは、サーバー側の再試行や並行要求を防げません。

再試行の対象を小さくする

モデルへの再要求と、ツール実行の再送は別です。文章生成をやり直すために、完了済みの業務操作まで再実行しないようにします。実行済みの結果を履歴へ戻し、その情報を使って最終説明だけを再開する方法を検討します。

重複防止の保存と外部操作の間に注目する

アプリで「実行済み」と保存する前に外部操作が成功し、その直後に停止すると、記録には成功が残らないことがあります。逆に先に成功を保存してから外部操作に失敗すると、未実行なのに完了扱いになります。保存先と実行先の境界を確認し、外部サービスの重複防止キーや状態照会を使えるかを調べます。キーを一つ付ければ常に一度だけ実行できる、という保証にはなりません。

実行の仕組みが一体化できない場合は、処理中の記録と復旧手順を用意します。どの状態なら再送でき、どの状態なら担当者の確認が必要かを決めます。失敗したときの説明へ「登録されていません」と断定する前に、実行先での結果を確かめる必要があります。二重実行を防ぐ設計と、利用者へ正しい状態を伝える設計を一緒に確認します。

再現例で判断の境界を確認する

同じ架空要求をPromise.allで同時に渡し、処理回数が一回になることと、同じキーで別内容を渡すと拒否することを確認します。

台帳は一つのプロセスのメモリ内だけです。再起動や複数サーバーでは共有されず、実運用には永続的な一意制約、結果保存、部分成功と再開の設計が必要です。決済や注文の実行コードではありません。

具体例で考える

架空の予約登録を送信した後、応答を受け取る前に通信が切れました。

説明用の入力・判断例
再開時:操作ID booking-request-17 の状態を確認。
登録済み:既存の予約番号を返す。
未実行:同じIDで実行。
確認不能:自動再登録せず要確認へ。

新しい予約要求として再送しない点が中心です。この例のIDは説明用で、実際には衝突しない識別方法と保存期間を、業務の再送可能期間に合わせて定めます。

ローカルで再現する:同時再送とキーの衝突を区別する

同じ架空要求をPromise.allで同時に渡し、処理回数が一回になることと、同じキーで別内容を渡すと拒否することを確認します。

以下は公開コードを実行して得た結果です。入力は学習用に作った架空データで、Claude API・外部サービスへの通信は行っていません。モデルの回答品質や実サービスの性能を測定した結果ではありません。

実行:
Node.js v24.22.1 · darwin/arm64 · 2ケース

再現の手順

  1. 実行コードを保存する(.mjs)
  2. Node.js 20以上の環境で、保存したフォルダから次のコマンドを実行します。追加パッケージやAPIキーは不要です。
  3. 出力の各ケースを読み、入力を一つ変えて判断の違いを確かめてください。
node claude-idempotent-actions.mjs

実際の入力と出力

{
  "cases": [
    {
      "name": "同じ要求を同時に二回",
      "input": {
        "key": "request-1",
        "payload": {
          "orderId": "sample-order",
          "amount": 1500
        },
        "calls": 2
      },
      "expectedEffects": 1,
      "actual": {
        "effects": 1,
        "results": [
          {
            "operationId": "local-1",
            "orderId": "sample-order"
          },
          {
            "operationId": "local-1",
            "orderId": "sample-order"
          }
        ]
      },
      "pass": true
    },
    {
      "name": "同じキーで内容を変更",
      "input": {
        "key": "request-1",
        "amount": 1700
      },
      "expected": "key_conflict",
      "actual": "key_conflict",
      "pass": true
    }
  ]
}
実行したコードを読む
// In-memory concurrency experiment only. Not a durable payment/order service.
export function createLedger() {
  const entries=new Map();let effects=0;
  return {get effects(){return effects;},
    execute(key,payload) {
      const fingerprint=JSON.stringify([payload.orderId,payload.amount]);
      const previous=entries.get(key);
      if(previous) {
        if(previous.fingerprint!==fingerprint)return Promise.reject(new Error('key_conflict'));
        return previous.result;
      }
      // Register the promise before its asynchronous work can begin.
      const result=Promise.resolve().then(()=>({operationId:`local-${++effects}`,orderId:payload.orderId}));
      entries.set(key,{fingerprint,result});return result;
    }
  };
}
export async function run() {
  const ledger=createLedger(),payload={orderId:'sample-order',amount:1500};
  const results=await Promise.all([ledger.execute('request-1',payload),ledger.execute('request-1',payload)]);
  const duplicate={name:'同じ要求を同時に二回',input:{key:'request-1',payload,calls:2},expectedEffects:1,actual:{effects:ledger.effects,results},pass:ledger.effects===1&&results[0].operationId===results[1].operationId};
  let conflict;try{await ledger.execute('request-1',{...payload,amount:1700});conflict='accepted';}catch(e){conflict=e.message;}
  return {cases:[duplicate,{name:'同じキーで内容を変更',input:{key:'request-1',amount:1700},expected:'key_conflict',actual:conflict,pass:conflict==='key_conflict'&&ledger.effects===1}]};
}
if(process.argv[1]&&import.meta.url===new URL('file://'+process.argv[1]).href)console.log(JSON.stringify(await run(),null,2));

コードのSHA-256:3807093875a149d5589ca2309bd185a03e242330c9a8eb11088e400f7c5fc4fa

5実験の実行記録を保存する(JSON)

この検証で分かる範囲

台帳は一つのプロセスのメモリ内だけです。再起動や複数サーバーでは共有されず、実運用には永続的な一意制約、結果保存、部分成功と再開の設計が必要です。決済や注文の実行コードではありません。

確認課題と合格の目安

操作成功後に応答だけが失われるケースを含め、再試行時の状態遷移を書いてください。

  • 同じ業務意図を同じ操作IDへ結び付ける。
  • 結果不明を正常な失敗と混同しない。
  • 生成の再試行で完了済みの操作を繰り返さない。