エージェントが支払いエンドポイントを呼び出し、請求は完了したものの、応答が戻る途中でタイムアウトしました。エージェントは 200 を確認できず再試行したため、顧客は二重請求されました。ログ上はエラーに見えない、エージェント特有の障害です。
人間は「支払う」を一度クリックして待機します。一方、再試行ループを持つエージェントは沈黙を失敗と判断し、短時間に複数回送信できます。再試行を増やすほど、重複書き込みの確率も上がります。対策は冪等性です。つまり、同じ論理操作を何度実行しても、1回実行した場合と同じ結果にすることです。
本記事では、HTTPレベルの冪等性、エージェントが再利用できるキーの生成方法、サーバー側の実装、二重請求を防ぐテスト方法を解説します。AIエージェントが本番環境で故障する理由で扱う「エージェントが二重実行した」問題の多くは、重複書き込みに起因します。
Apidogでは、同一リクエストを2回実行し、2回目が状態を変更しないことをテストシナリオとして保存・CI実行できます。
エージェントが冪等性を壊しやすい理由
重複リクエストが起こる主な理由は3つです。
積極的な再試行
エージェントフレームワークは一時的なネットワーク障害に備え、デフォルトで再試行します。エージェントのエラー回復で紹介されるバックオフやサーキットブレーカーも、結果として同じリクエストがサーバーへ届く回数を増やします。タイムアウトの曖昧さ
504は「書き込みが実行されなかった」ことも、「書き込み後に応答だけ失われた」ことも意味します。人間は確認してから再試行しがちですが、エージェントでは確認自体が追加のツール呼び出しになります。タスク全体の再実行
ステップ1で注文を作成し、ステップ4で失敗した場合、単純な再起動は2つ目の注文を作ります。マルチステップのエージェントでは、再試行境界をモデルが判断するため、スクリプトより危険です。
問題は、エージェントが不正なリクエストを送ることではありません。正しいリクエストを複数回送ることです。
冪等性が保証すること
同じ操作を複数回実行しても、1回実行した場合と同じ効果だけが残るなら、その操作は冪等です。
GET、PUT、DELETE は、RFC 9110で冪等と定義されています。POST は冪等ではありません。注文作成、メール送信、送金開始といった危険な操作が POST になりやすい理由です。
冪等性と安全性は別の概念です。安全なメソッドは状態を変更しませんが、DELETE は冪等であっても破壊的です。5回呼び出してもリソースは1回削除された状態に留まりますが、削除自体は発生します。エージェントでは、これらを分けて扱う必要があります。エージェント向け最小権限APIキーも、この点を認証情報の側面から扱っています。
また、冪等性は「毎回同じHTTP応答を返す」ことではありません。重要なのはサーバー状態です。請求は1回、注文は1件、メールは1通だけでなければなりません。
Idempotency-Key で POST を冪等にする
一般的なパターンは、クライアントが生成したキーをリクエストに含める方法です。サーバーはキーと実行結果を保存し、同じキーを受け取った場合は処理を再実行せず、保存済みの結果を返します。
Stripeがこの方式を広く普及させました。Stripeの冪等性ドキュメントと、IETFのIdempotency-Keyヘッダーフィールドを参照してください。
POST /v1/payments HTTP/1.1
Host: api.yourservice.com
[REDACTED CREDENTIAL] [REDACTED]...
Idempotency-Key: 9f2b7c14-6d3a-4b18-9d55-1e2a7c0b4f31
Content-Type: application/json
{
"amount": 4900,
"currency": "usd",
"customer_id": "cus_8812",
"description": "Pro plan, August"
}
キーはUUIDのような不透明な値で構いません。サーバーはキー、リクエストボディのフィンガープリント、生成した応答を関連付けて保存します。
エージェントで再利用可能なキーを生成する
よくある失敗は、ツールラッパーが呼び出しごとに新しいUUIDを生成することです。再試行ごとにキーが変わるため、冪等性は機能しません。
キーはHTTP試行ではなく、論理操作に紐付けます。
エージェントがアクションの実行を決定した時点でキーを生成し、その操作に対するすべての再試行で同じキーを使います。
import uuid
class PaymentTool:
def __init__(self, client):
self.client = client
self._keys = {}
def charge(self, task_id, step_id, amount, customer_id):
# One key per (task, step). Retries of the same step reuse it.
op = f"{task_id}:{step_id}"
if op not in self._keys:
self._keys[op] = str(uuid.uuid4())
return self.client.post(
"/v1/payments",
headers={"Idempotency-Key": self._keys[op]},
json={"amount": amount, "customer_id": customer_id},
)
プロセス再起動後もキーを維持する必要がある場合は、タスクID・ステップID・ペイロード return hashlib.sha256(raw.encode()).hexdigest()[:32]
キーをタイムスタンプや試行ごとの乱数から作ってはいけません。タスクが本当に新しい請求として再作成された場合だけ、新しいタスクIDにより新しいキーが生成されるべきです。
### サーバー側の実装要件
正しい実装には、単なるキー検索以上の処理が必要です。
- **作業前にキーを確保する**
ユニーク制約のあるテーブルへ最初に挿入します。挿入失敗は、別の試行がすでにそのキーを処理していることを意味します。
- **同じキー・異なるペイロードは `422` にする**
キャッシュ済みの結果を返すとクライアント側のバグを隠します。
- **処理中の重複は `409` にする**
呼び出し元に競合ではなくバックオフを促します。
- **完了後は応答を保存する**
ステータスコードとレスポンスボディを保存し、以後の同一キーには保存済みの結果を返します。
sql
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
state TEXT NOT NULL, -- in_progress | completed
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
キーには有効期限を設定してください。24時間は現実的な再試行期間をカバーしつつ、テーブルの無制限な増加を防ぐ妥当なデフォルトです。Stripeも24時間後にキーを期限切れにします。
## 2回目の呼び出しが状態を変えないことをテストする
冪等性は実装するだけでは不十分です。2回目のリクエストが何も変更しないことをテストで証明してください。
手順はシンプルです。
1. 固定した `Idempotency-Key` で `POST` を送る
2. 応答とリソースIDを保存する
3. 同じリクエストをもう一度送る
4. リソースや残高などの状態を検証する
応答の成功だけでは不十分です。二重請求でも2回とも `200` になる可能性があります。次を確認してください。
- 2回目の応答が1回目と同じリソースIDを返す
- その後の `GET` でレコード数が2件ではなく1件である
- カウンターや残高が1回だけ変化している
[Apidog](https://apidog.com?utm_source=dev.to&utm_medium=wanda&utm_content=n8n-post-automation)では、固定キーでの `POST`、同一リクエストの再送、リソース一覧の件数検証を1つのテストシナリオとして保存できます。ステップ1の応答IDを変数へ保存し、ステップ2が同じIDを返すことをアサートしてください。保存済みシナリオをCIで実行すれば、支払い処理の変更による回帰を検出できます。[API契約テストガイド](https://apidog.com/jp/blog/api-contract-testing?utm_source=dev.to&utm_medium=wanda&utm_content=n8n-post-automation)のパターンにも適用できます。

さらに、次のケースもテストしてください。
- **同じキーで異なるボディ**: 成功ではなく `422` を返す
- **同時重複**: 2つのリクエストを同時送信し、正確に1つの処理だけが実行されることを確認する
同時重複テストは、シーケンシャルなテストでは検出できないユニーク制約の欠落を見つけます。
支払いAPIがまだない段階では、冪等性を認識するモックを用意して再試行ロジックを検証してください。[エージェントが本番環境ではなくモックを叩くべき理由](https://apidog.com/jp/blog/ai-agents-mock-apis-not-production?utm_source=dev.to&utm_medium=wanda&utm_content=n8n-post-automation)も参考になります。
## キーを追加できない場合の対策
自分が所有していないAPIで冪等性がサポートされない場合は、次の順で検討します。
- **操作自体を冪等にする**
クライアント指定のリソースパスに対する `PUT /orders/{client_order_id}` は構造上冪等です。API設計を制御できるなら、`POST` とヘッダーの組み合わせより優先できます。
- **書き込み前に確認する**
同じ自然キーを持つ既存レコードを検索してから作成します。ただし、確認と書き込みの間に競合が起こるため、完全な対策ではありません。
- **ダウンストリームで重複排除する**
メッセージやイベントには安定したメッセージIDを付与し、コンシューマ側で重複を破棄します。[信頼性の高いWebhookガイド](https://apidog.com/jp/blog/how-to-design-reliable-webhooks?utm_source=dev.to&utm_medium=wanda&utm_content=n8n-post-automation)と組み合わせると効果的です。
- **人間の承認でゲートする**
不可逆で冪等にできない操作は、人間の承認を挟みます。[AIエージェントのガードレール](https://apidog.com/jp/blog/ai-agent-guardrails?utm_source=dev.to&utm_medium=wanda&utm_content=n8n-post-automation)で紹介される承認ゲートは、重複コストが高い操作に適しています。
## 実行IDを記録して追跡可能にする
冪等性は重複を防ぎますが、どの実行がレコードを作成したかまでは示しません。インシデント後の調査に備え、タスクID・ステップID・冪等性キーをすべての試行で記録してください。
エージェントがコーディングランタイムである場合、実行IDと元タスクを紐付けて保持します。[Sharkly](https://sharkly.ai)では、各実行が元のタスクに紐付き、実行状態と結果がコメントスレッドとともに保存されます。これにより、重複書き込みを匿名の再試行ではなく、特定の実行まで追跡できます。

## 出荷前チェックリスト
- エージェントが呼び出すすべての非冪等ツールに冪等性キーがあり、キーなしの送信を拒否する
- キーはHTTP試行ではなく、タスクとステップから導出する
- サーバーは作業前にキーを確保する
- 同じキーで異なるペイロードが来た場合、キャッシュ済み応答ではなくエラーを返す
- 同時重複はアプリケーションのタイミングではなく、データベースのユニーク制約で処理する
- 保存済みテストが、2回目の呼び出しで状態が変わらないことを証明し、CIで実行される
- キーを期限切れにし、レコードを定期的にクリーンアップする
このチェックリストを満たせば、再試行ポリシーを控えめにする必要はありません。冪等性があれば、エージェントを危険にせずレジリエントにできます。
## よくある質問
**読み取り専用ツールにも冪等性キーは必要ですか?**
不要です。`GET` はすでに冪等かつ安全です。キーは、作成・請求・送信など状態を変更する操作に限定してください。
**キーはエージェント内とツールラッパー内のどちらで生成すべきですか?**
ツールラッパー内です。エージェントのタスクIDとステップIDを基に生成してください。モデルにキーを生成させると、再試行時の再生成やタスク間の衝突が起こり得ます。
**重複リクエストにはどのステータスコードを返すべきですか?**
最初の呼び出しで保存したステータスを返します。最初に `201` を返した場合、再送にも同じボディで `201` を返します。`Idempotent-Replay: true` のようなヘッダーを追加すると、デバッグに役立ちます。
**キーはどのくらい保持すべきですか?**
24時間でほとんどの再試行期間をカバーできます。それ以降の再試行は新しい操作として扱い、テーブルの無制限な増大を防ぎます。
**冪等性キーはトランザクションを置き換えますか?**
置き換えません。冪等性キーは重複リクエストによる重複効果を防ぎ、トランザクションは単一リクエストをアトミックに保ちます。両方が必要です。可能ならキーの確保も作業と同じトランザクションで行ってください。
**実際の支払いプロバイダーなしでテストするには?**
ペイロード不一致時の `422` を含め、キーのセマンティクスを実装したモックへエージェントを向けます。[モックAPIに対するAIエージェントのテストガイド](https://apidog.com/jp/blog/ai-agents-mock-apis-not-production?utm_source=dev.to&utm_medium=wanda&utm_content=n8n-post-automation)を参照してください。モックと再試行テストを同じプロジェクトで管理するなら、[Apidogをダウンロード](https://apidog.com/download?utm_source=dev.to&utm_medium=wanda&utm_content=n8n-post-automation)してください。
Top comments (0)