DEV Community

Cover image for AI Agent Idempotency: Stop Retries From Double-Charging
Hassann
Hassann

Posted on Originally published at apidog.com

AI Agent Idempotency: Stop Retries From Double-Charging

Idempotency for AI Agents: Prevent Duplicate Charges and Writes

Your agent called the payment endpoint. The request succeeded, the charge landed, but the response timed out. The agent never received a 200, retried, and charged the customer twice. Your logs may show no obvious error.

Try Apidog today

Agents amplify this failure mode. They retry faster and more often than people, and a failed multi-step run may restart from the beginning. Idempotency lets retries be safe: repeating the same logical request produces the same server-side effect as performing it once.

This guide explains how to implement idempotency keys for agent tools, persist and replay results on the server, and test that duplicate requests change nothing. For broader production reliability, see why AI agents break in production.

Idempotency workflow for agent requests

Why agents create duplicate writes

Agent traffic makes duplicates likely for three reasons:

  1. Aggressive retries. Retry, backoff, and circuit-breaker strategies improve reliability but increase the number of requests reaching your API. See agent error recovery.
  2. Timeout ambiguity. A timeout cannot tell the client whether the server completed the write before the response was lost.
  3. Fuzzy retry boundaries. If step one creates an order and step four fails, a restarted agent run can create another order unless step one is protected.

The requests are not necessarily invalid. They are valid requests sent more than once.

What idempotency guarantees

An operation is idempotent when executing it multiple times has the same effect as executing it once.

GET, PUT, and DELETE are idempotent under RFC 9110. POST is not, which makes it dangerous for operations such as creating orders, sending messages, and charging cards.

Two distinctions matter:

  • Idempotent does not mean safe. DELETE is idempotent, but it still removes data.
  • Idempotent does not require identical responses. The state must remain unchanged: one charge, one order, or one email. Returning the original response is usually the most useful behavior.

Use least-privilege credentials as a separate control; see least-privilege API keys for agents.

Make POST safe with idempotency keys

Send a client-generated key with every state-changing request. The server stores the key, a request fingerprint, and the original response. Later requests with the same key replay that result instead of repeating the work.

This pattern is widely used by Stripe and is being standardized as the Idempotency-Key HTTP header.

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"
}
Enter fullscreen mode Exit fullscreen mode

The key identifies a logical operation, not an individual HTTP attempt.

Generate keys that survive retries

Do not generate a new UUID for every tool call. A fresh key on every retry defeats idempotency.

Generate the key when the agent decides to take an action, then reuse it for every retry of that task step:

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},
        )
Enter fullscreen mode Exit fullscreen mode

For process restarts, derive the key deterministically:

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]
Enter fullscreen mode Exit fullscreen mode

Derive keys from the task and step, never from a timestamp or a random value regenerated per attempt. A genuinely new task receives a new task ID and therefore a new key.

Store and enforce keys on the server

A correct server-side implementation must handle sequential and concurrent duplicates.

  1. Claim the key first. Insert it with a database uniqueness constraint before doing the work.
  2. Reject payload mismatches. If the same key has a different request fingerprint, return 422 Unprocessable Entity.
  3. Handle in-flight requests. If the key exists but work is still running, return 409 Conflict so the caller can back off.
  4. Replay completed results. Store the original status and body, then return them for later requests.
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
);
Enter fullscreen mode Exit fullscreen mode

Expire records. A 24-hour retention window covers realistic retry periods and prevents indefinite table growth. Stripe uses the same default.

Testing duplicate requests in an API workflow

Test that the second request changes nothing

A successful response does not prove idempotency. Two duplicated charges can both return 200.

Test server state instead:

  • The second response has the same resource ID and body as the first.
  • A follow-up GET returns one resource, not two.
  • Balances, counters, or downstream events change once.

In Apidog, create a scenario:

  1. Send a POST with a fixed Idempotency-Key.
  2. Repeat the exact request with the same key.
  3. List the affected resource.
  4. Assert that the count is one and both responses contain the same resource ID.

Save the first response ID as a variable and assert that the replay returns it. Run the scenario in CI alongside your API contract tests.

Also test these cases:

  • Same key, different body: expect 422, not a cached success.
  • Concurrent duplicates: send two identical requests simultaneously and verify only one succeeds in doing the work.

When the real API is unavailable, use an idempotency-aware mock so the agent’s retry behavior is exercised early. See why agents should use mocks instead of production.

When you cannot add idempotency keys

If an external API has no idempotency support, use the strongest available alternative:

  1. Use naturally idempotent writes. Prefer a client-selected resource path such as PUT /orders/{client_order_id} when you control the API.
  2. Check before writing. Query for an existing record by a natural key. This reduces common duplicates but does not eliminate race conditions.
  3. Deduplicate downstream. Attach a stable event or message ID and make consumers discard repeats. This is a standard event-driven pattern; see reliable webhooks.
  4. Add human approval. For irreversible operations that cannot be made idempotent, require approval using an AI agent guardrail.

Keep run identity for incident investigation

Idempotency prevents duplicate effects, but incident response still needs to answer which run created the record.

Log the task ID, step ID, idempotency key, and attempt number for every request. Platforms such as Sharkly associate runs with their originating tasks and execution results, making it possible to trace a write back to a specific run rather than an anonymous retry.

Shipping checklist

  • Every state-changing agent tool requires an idempotency key.
  • Keys derive from task and step IDs, not attempts.
  • The server claims the key before performing work.
  • Payload mismatches return an error.
  • Concurrent duplicates rely on a database uniqueness constraint.
  • A saved CI test proves the second request changes nothing.
  • Keys expire and completed records are cleaned up.

Idempotency lets you make agent retries more aggressive without making state-changing tools more dangerous.

FAQ

Do read-only tools need idempotency keys?

No. GET requests are already safe and idempotent. Use keys for calls that create, charge, send, or otherwise change state.

Where should the key be generated?

Generate it in the tool wrapper using the agent’s task and step identifiers. Do not ask the model to generate it: models may regenerate values on retries or collide across tasks.

What should a repeated request return?

Return the original status and body. If the first request returned 201, the replay should return 201 with the same response body. An optional header such as Idempotent-Replay: true can help debugging.

How long should keys be retained?

Twenty-four hours is a practical default. Treat retries after expiry as new operations.

Does idempotency replace transactions?

No. Idempotency prevents duplicate effects across requests. Transactions make one request atomic. Use both, and claim the key in the same transaction as the underlying work whenever possible.

How can I test this without a payment provider?

Point the agent at a mock that implements key replay and 422 payload-mismatch behavior. You can keep the mock and retry tests in one project with Apidog.

Top comments (0)