エージェントが支払いエンドポイントを呼び出しました。リクエストは正常に処理され、請求は完了しましたが、応答が戻る途中でタイムアウトしました。エージェントは 200 を確認できなかったため、失敗時に指示されたとおりに再試行しました。その結果、顧客には二重に請求され、ログにはエラーらしきものは何も記録されていません。
これは、エージェントを通常のAPIクライアントと区別する障害モードです。人間が「支払う」を一度クリックすると、スピナーが表示され、待機します。再試行ループにあるエージェントは沈黙を察知し、時には人間よりも速く、立て続けに3、4回再試行します。エージェントの信頼性を高めるために追加するすべての再試行ポリシーは、重複書き込みの可能性も高めます。その解決策は멱等性(べきとうせい)です。つまり、繰り返し行われたリクエストが単一のリクエストと同じ結果を生み出すようにすることです。
このガイドでは、HTTPレベルでの멱等性の意味、エージェントが実際に再利用できるキーを生成する方法、サーバーがそれらを尊重するために何を保存する必要があるか、そして実際の顧客が二重に請求される前に全体をテストする方法について説明します。AIエージェントが本番環境で故障する理由に関する当社の柱をまだお読みでない場合、「エージェントが二重に実行した」という報告のほとんどの根底に隠されている障害モードは、重複書き込みです。
このテストの後半でApidogが登場します。멱等性は、APIとエージェントのツールレイヤーに組み込むものです。その後に必要となるのは、同じリクエストを2回実行し、2回目が何も変更しなかったことを証明する方法です。これはCIで保存して実行できるテストです。
エージェントが人よりも멱等性を破る頻度が高い理由
エージェントのトラフィックには、重複を頻繁に発生させる3つの要因があります。
1つ目は再試行の多さです。エージェントフレームワークは、一時的なネットワーク障害が実行失敗の最も一般的な原因であるため、デフォルトで積極的に再試行します。エージェントのエラー回復に関する当社のガイドでは、バックオフやサーキットブレーカーについて解説しており、これらのテクニックはすべて、特定のリクエストがサーバーに到達する回数を増加させます。
2つ目はタイムアウトの曖昧さです。リクエストがタイムアウトした場合、クライアントはサーバーがそれを処理したかどうかについて何も知ることができません。プロキシからの 504 は、書き込みがまったく行われなかったか、書き込みは行われたが応答が失われたことを意味する可能性があります。人間は通常、再試行する前に確認しますが、エージェントは通常そうしません。「まず確認する」ということは、モデルが実行するかどうかを決定しなければならない追加のツール呼び出しだからです。
3つ目はループです。タスクに失敗したエージェントは、失敗したステップだけでなく、タスク全体を再起動する可能性があります。ステップ1で注文を作成し、ステップ4で失敗した場合、安易な再起動は2番目の注文を作成します。これは、マルチステップエージェントがスクリプトと大きく異なる点です。再試行の境界が曖昧で、コードではなくモデルがどこから再開するかを決定するのです。
これらをまとめると、問題の全体像が見えてきます。エージェントが不正なリクエストを送信するわけではありません。エージェントは正しいリクエストを複数回送信するのです。
멱等性が実際に保証するもの
ある操作を複数回実行しても、1回実行した場合と同じ効果が得られるとき、その操作は멱等性であると言えます。GET、PUT、DELETE は、HTTPセマンティクス仕様であるRFC 9110で멱等であると定義されています。POST はそうではありません。これがまさに、危険な操作が POST 呼び出し(注文の作成、メッセージの送信、転送の開始など)になりがちな理由です。
2つの補足説明が、多くの混乱を解消します。
멱等であることと安全であることは同じではありません。安全なメソッドは何も変更しません。DELETE は멱等ですが破壊的です。5回呼び出しても、1回呼び出した場合と同じくリソースは削除されたままですが、リソースは依然として消滅しています。エージェントはこれら2つの特性を別々に扱う必要があります。これは、エージェント向け最小権限APIキーに関する当社の投稿が、認証情報側から主張する内容です。
멱等であることは、まったく同じ応答が返ってくることとも異なります。2回目の呼び出しでは、1回目の呼び出しで保存された結果が返されることもあれば、異なるステータスコードが返されることもあります。変更されてはならないのは、サーバー上の状態です。1回の請求。1つの注文。1通のメール。
멱等性キー: POSTを安全にするパターン
標準的な解決策は、クライアントが生成したキーをリクエストとともに送信することです。サーバーはそのキーを結果とともに記録し、同じキーを持つ後続のリクエストは、作業を再度実行する代わりに記録された結果を返します。
Stripeがこのヘッダーを普及させ、Stripeの멱等性に関するドキュメントは、そのセマンティクスを最も明確に記述しています。また、IETFではこれをIdempotency-Keyヘッダーフィールドとして標準化する取り組みも進められており、独自のヘッダー名を考案する前に一読する価値があります。
リクエストは次のようになります。
POST /v1/payments HTTP/1.1
Host: api.yourservice.com
Authorization: Bearer sk_live_...
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},
)
決定的なキーも機能し、メモリ内の辞書とは異なり、プロセスが再起動してもキーは存続します。
import hashlib
def idempotency_key(task_id: str, step_id: str, payload: dict) -> str:
raw = f"{task_id}|{step_id}|{sorted(payload.items())}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
キーはタスクの実行とステップから派生させ、タイムスタンプや試行ごとに再生成されるランダムな値から派生させてはなりません。エージェントがタスク全体を再起動し、本当に新しい請求を意図している場合、タスクIDが変更され、キーも変更されます。それが望ましい動作です。
サーバーがすべきこと
ヘッダーを正しく処理するには、ルックアップ以上の作業が必要です。機能する実装では4つのことを行います。
- 到着時にキーの確保を試みます。何らかの作業を行う前に、ユニーク制約付きのテーブルに挿入します。挿入に失敗した場合、他の試行がそのキーを所有しています。
- キーが存在し、保存されているリクエストのフィンガープリントが異なる場合、
422で拒否します。異なるボディで同じキーが送られてきた場合、クライアントのバグを意味し、古い結果を黙って返すことはそれを隠蔽することになります。 - キーが存在し、最初の試行がまだ処理中の場合、
409を返して、呼び出し元が競合するのではなくバックオフするように促します。 - 作業が完了したら、ステータスコードとボディをキーに関連付けて保存し、それ以降のすべてのヒットに対してそれを返します。
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回の成功した請求はどちらも 200 を返します。
したがって、応答ではなく状態をアサートします。
- 2番目の応答ボディが、リソースIDを含め、1番目の応答と一致すること。新しいIDは新しいリソースが作成されたことを意味します。
- コレクションに対するその後の
GETが、2つではなく1つのレコードを返すこと。 - いずれかのカウンターまたは残高が1回だけ移動したこと。
Apidogでは、これをテストシナリオとして設定できます。ステップ1では固定の Idempotency-Key を付けて POST を送信し、ステップ2ではそれを繰り返し、ステップ3ではリソースをリストしてカウントをアサートします。ステップ1からの応答IDを変数に保存し、ステップ2が同じ値を返すことをアサートします。シナリオ全体が保存されているため、支払いパスに変更があるたびにCIで実行され、これにより実際に回帰が検出されます。同じ手法は、API契約テストガイドのより広範なパターンにも適用されます。

実際のバグを発見するため、さらに2つのケースを取り上げる価値があります。
- 同じキーで異なるボディ。黙って成功するのではなく、
422を期待します。 - 同時重複。両方のリクエストを同時に発行し、正確に1つだけが成功することを確認します。これは、シーケンシャルテストでは決して表面化しない、欠けているユニーク制約を検出します。
ここでもモックが役立ちます。エージェントをまだ構築中で支払いAPIが存在しない場合、멱等性を意識した応答でモックを作成し、エージェントの再試行ロジックを早期に試行できるようにします。エージェントが本番環境ではなくモックを叩くべき理由に関する当社の投稿は、この習慣のより広範な理由を説明しています。
キーを追加できない場合
APIがあなたの所有物ではなく、멱等性サポートがない場合もあります。その場合でも、おおよその優先順位でいくつかの選択肢があります。
- 操作を自然に멱等にする。クライアントが選択するリソースパスへの
PUTは、構造上멱等です:PUT /orders/{client_order_id}。API設計を制御できるのであれば、POSTとヘッダーの組み合わせよりもこれを優先してください。追加のテーブルは必要ありません。 - 書き込み前に確認する。エージェントが新しいレコードを作成する前に、同じ自然キーを持つ既存のレコードを照会するようにします。これは、チェックと書き込みの間に競合が発生して2つのレコードが生成される可能性があるため、より弱い方法ですが、一般的なタイムアウトのケースを解消します。
- ダウンストリームで重複排除する。書き込みがメッセージまたはイベントである場合、コンシューマで重複排除を行います。安定したメッセージIDを付与し、コンシューマが繰り返しを破棄するようにします。これはイベント駆動型システムにおける標準的なプラクティスであり、信頼性の高いWebhookガイドのガイダンスと組み合わせて使用できます。
- アクションをゲートする。本当に不可逆的で멱等にできない操作の場合、人間の承認を挟みます。これは、AIエージェントのガードレールに関する投稿からの承認ゲートパターンであり、重複のコストが十分に高い場合に適切な解決策です。
どの実行が何をしたかを知る
멱等性は重複を防ぎます。しかし、どの試行がレコードを作成したかを教えてくれるわけではなく、それがインシデント後に尋ねられる質問です。
実行IDを作業に紐付けたままにします。エージェントが自社サービスの場合、それは上記のキー派生から得られたタスクIDとステップIDを意味し、すべての試行でログに記録されます。エージェントが割り当てられた作業を実行するコーディングランタイムである場合、プラットフォームが通常それを保持します。Sharklyでは、各実行は元のタスクに紐付けられ、その実行状態と結果はコメントスレッドとともに保存されるため、繰り返しの書き込みは匿名の再試行ではなく、特定の実行に遡って追跡できます。

出荷前のチェックリスト
- エージェントが呼び出すすべての非멱等なツールには멱等性キーが必要であり、ツールラッパーはキーなしでの送信を拒否します。
- キーは試行からではなく、タスクとステップから派生します。
- サーバーは作業を行う前にキーを確保し、後からではありません。
- 異なるペイロードで同じキーが送信された場合、キャッシュされた応答ではなくエラーを返します。
- 同時重複は、アプリケーションのタイミングではなく、データベースの制約によって処理されます。
- 保存されたテストにより、2回目の呼び出しが何も変更しないことが証明され、それがCIで実行されます。
- キーはスケジュールに基づいて期限切れになり、テーブルがクリーンアップされます。
このリストをこなせば、二重請求の話は起こらなくなり、再試行ポリシーを控えめにするのではなく、より積極的に設定できるようになります。これが真の成果です。멱等性こそが、エージェントを危険にすることなく、レジリエントにするための手段なのです。
よくある質問
読み取り専用ツールにも멱等性キーが必要ですか? いいえ。GET リクエストはすでに멱等であり安全なので、再試行してもわずかな遅延が発生するだけで、他に何も損失はありません。キーは、作成、請求、送信、その他状態を変更する呼び出しのために予約してください。
キーはどこで生成すべきですか、エージェント内ですか、それともツールラッパー内ですか? ツールラッパー内で、エージェントのタスクおよびステップ識別子を基にして生成すべきです。モデルにキーを生成させるのは間違いです。モデルは再試行時に値を再生成し、タスク間で衝突を引き起こす可能性があります。
繰り返されたリクエストはどのステータスコードを返すべきですか? 元の呼び出しで保存されたステータスを返すべきです。そのため、最初に 201 を返した2回目の POST は、同じボディで再び 201 を返します。一部のAPIでは、繰り返しであることを示すために Idempotent-Replay: true のようなヘッダーを追加しますが、これはデバッグに役立ち、それを無視するクライアントには無害です。
キーはどのくらいの期間保持すべきですか? 24時間あれば、ほとんどすべての再試行期間をカバーできます。より長く保持してもほとんど意味がなく、テーブルが際限なく増大します。クライアントがその期間後に再試行した場合は、新しい操作として扱います。
これはトランザクションを置き換えますか? いいえ。멱等性キーは重複したリクエストが重複した効果を生み出すのを防ぎます。トランザクションは単一のリクエストをアトミックに保ちます。両方が必要であり、データベースが許す限り、キーの確保は作業と同じトランザクションで書き込まれるべきです。
実際の支払いプロバイダーなしでこれをテストするにはどうすればよいですか? ペイロードの不一致時の 422 を含む、キーのセマンティクスを実装したモックにエージェントを向けます。モックAPIに対するAIエージェントのテストガイドでセットアップについて説明しており、モックと再試行テストを同じプロジェクトで管理したい場合はApidogをダウンロードしてください。
