Blog · · 10 min read

AI agent idempotency: make side effects safe

AI agent idempotency: make side effects safe

An agent does not have to choose an action twice for your product to perform it twice. A worker can retry after a timeout. A checkpoint can replay. Two workers can race for the same job. Repeating a read is usually harmless. Repeating a charge, refund, email, permission change, or deletion is a production incident.

AI agent idempotency gives one logical action one durable identity, even when the runtime makes several attempts. To make that work, put a stable idempotency key and action record in front of the API call. Then define which failures are safe to retry and how to compensate when part of a larger workflow has already completed.

The safe execution contract

Before an agent can write to a production system, its execution layer should enforce these rules:

  1. The system gives one logical action one stable identity.
  2. A retry with the same identity and the same arguments returns the recorded outcome instead of repeating the effect.
  3. The system rejects the same identity with different arguments.
  4. A timeout produces an unknown outcome until the system reconciles it.
  5. A multi-step action records how to compensate for completed steps if a later step fails.

This contract belongs in the execution layer, not in the prompt. Prompts can reduce accidental tool calls, but they cannot coordinate workers or prove whether a remote API completed before the connection died. AWS makes the same distinction in its guidance on making retries safe with idempotent APIs: retries are useful only when repeated calls do not create unintended side effects.

Why duplicate tool calls happen

Most duplicates come from ordinary recovery behavior, not from the model asking twice.

A worker can send a request, lose the response, and retry. The remote service may already have committed the change. A durable workflow can resume from a checkpoint that predates the side effect. Two workers can also claim the same job before either one records completion.

Persistence solves one problem while exposing another. LangGraph persistence lets an application resume after an interruption or recover from a failure. That is useful, but a checkpoint of local graph state cannot make an external charge or email transactional. A reported LangGraph issue involving long tool calls describes duplicate executions after checkpoint recovery. Treat that issue as a practitioner report, not proof that every runtime behaves the same way. The failure mode itself is general: local replay can repeat remote work unless the remote action has its own identity.

The dangerous moment is a timeout after dispatch:

agent worker             action API                external system
     |                        |                           |
     |-- create refund -----> |                           |
     |                        |-- commit refund --------> |
     |        connection drops before response           |
     |<--------- timeout -----|                           |
     |-- retry refund ------> |   duplicate unless keyed |

You do not know whether the action failed. You only know that the caller did not receive a result.

Give each logical action an idempotency key

Generate the key before dispatch and persist it with the planned action. The key must survive retries, process restarts, checkpoint replay, and handoff to another worker.

A random UUID works when the caller creates it once and stores it before the first attempt. A deterministic key can also work if it includes stable scope and a canonicalized argument hash. For example:

key_material = tenant_id
             + conversation_id
             + logical_action_id
             + tool_name
             + canonical_json(arguments)

idempotency_key = sha256(key_material)

Do not generate a new UUID inside each retry loop. That gives every attempt a new identity and defeats deduplication. Do not use the raw natural-language instruction as a key either. Equivalent instructions can have different wording, and similar wording can express different intent.

The receiving API should bind the key to the request arguments. Stripe's idempotent request contract records the first result for a key and returns that result for later matching requests. It also compares parameters and rejects incompatible reuse. That second behavior matters: silently accepting a reused key with new arguments can return a successful result for the wrong action.

A practical key record contains:

{
  "scope": "tenant_42",
  "idempotency_key": "act_01J...",
  "tool": "issue_refund",
  "arguments_hash": "sha256:...",
  "logical_action_id": "action_123",
  "created_at": "2026-07-14T08:30:00Z"
}

Scope uniqueness by tenant or account. A global key that leaks across tenants can turn deduplication into a data-isolation bug.

Put a durable action record in front of every write

The idempotency key answers "is this the same action?" The action record answers "what happened?"

Create the record before calling the external system. Store enough data to reconcile the result without asking the model to reconstruct it later. A useful state machine is:

planned
  -> awaiting_approval
  -> executing
  -> succeeded
  -> failed_retryable
  -> failed_terminal
  -> unknown
  -> compensating
  -> compensated

The action record should include the tenant, acting user, conversation or run, tool name, canonical arguments, idempotency key, attempt count, current state, external request ID, result summary, timestamps, and compensation data. Keep secrets and raw credentials out of the record.

Use one transaction to insert the action record or claim an existing one. A unique constraint on (tenant_id, idempotency_key) is the final defense against concurrent workers:

CREATE UNIQUE INDEX agent_actions_tenant_key
ON agent_actions (tenant_id, idempotency_key);

Execution then becomes a lookup rather than a blind call:

execute(action):
  record = insert_or_get(action.idempotency_key, action.arguments_hash)

  if record.arguments_hash != action.arguments_hash:
      return conflict("key reused with different arguments")

  if record.status == "succeeded":
      return record.result

  if record.status in ["executing", "unknown"]:
      return pending(record.id)

  claim record for execution
  call external API with the same idempotency key
  persist outcome
  return outcome

Do not hold a database transaction open while waiting on a remote API. Claim the record atomically, commit, make the call, and then persist the result. A lease or compare-and-swap update can recover records left in executing by a dead worker.

Retry only when the action contract permits it

"Retry on error" is too vague for an action-taking agent. The retry decision should depend on both the failure and the action state.

Retry transport failures, rate limits, and documented transient server failures with exponential backoff and jitter. Reuse the same idempotency key on every attempt. Stop after a bounded number of attempts, then move the record to unknown or failed_retryable for later reconciliation.

Do not automatically retry validation errors, authorization failures, policy denials, or parameter conflicts. These require a new request, user input, or operator action. Do not let the model reinterpret an error and silently issue a slightly different write under the same logical action ID.

The retry table should distinguish a new attempt from a new action:

EventNew attempt?New logical action?
Network timeout before a known responseYesNo
Rate limit responseYesNo
User changes the refund amountNoYes
Authorization deniedNoNo, stop
Operator explicitly repeats a completed actionNoYes, with confirmation

Reconcile unknown outcomes before retrying

Use unknown when the system cannot prove whether the side effect happened. Do not force an ambiguous timeout into either success or failure.

If the external API supports idempotency lookup, query by the same key. Otherwise, use a provider request ID or a product-specific correlation field. Reconciliation should end in one of three decisions:

  • The external action succeeded. Record the result without dispatching it again.
  • The external action definitely did not happen. Return it to a retryable state.
  • The result is still ambiguous. Keep it blocked and surface it to an operator or user.

Do not ask the model to guess. The model has no privileged knowledge of a payment processor, email provider, or database commit.

Expose this state in the product. "Still checking whether the refund completed" is more useful than a generic spinner followed by an unexplained second refund.

Use compensation for multi-step work

Idempotency prevents duplicate execution of one action. It does not make a workflow of several external actions atomic.

Suppose an agent reserves inventory, charges a card, and creates a shipment. If shipment creation fails after the charge succeeds, retrying the whole workflow can make things worse. The workflow needs a compensating action such as releasing inventory or refunding the charge.

Microsoft's Compensating Transaction pattern describes compensation as work that undoes completed steps in an eventually consistent operation. Compensation is business logic, not a database rollback. It may run later, fail independently, or require human approval.

Record compensation beside each completed step:

{
  "step": "charge_customer",
  "status": "succeeded",
  "external_id": "ch_123",
  "compensation": {
    "tool": "refund_charge",
    "arguments": {"charge_id": "ch_123"},
    "idempotency_key": "comp_action_123_charge"
  }
}

The compensation itself also needs idempotency. A recovery worker can crash while issuing the refund just as easily as the original worker can crash while issuing the charge.

Some effects cannot be undone. An email cannot be unsent, and a deleted external record may not be recoverable. For these actions, reduce the blast radius before execution: require approval, show a preview, delay dispatch, or write to a reversible staging state.

Keep model reasoning separate from action execution

The model can propose an intent and arguments. A deterministic execution layer should decide whether that proposal is authorized, approved, new, in progress, complete, retryable, or compensatable.

Put that boundary in this order:

model proposal
  -> schema validation
  -> authorization and policy
  -> approval when required
  -> create or load action record
  -> idempotent connector call
  -> persist result
  -> summarize result for model and user

This boundary also improves observability. Every user-visible action has one durable record even if it has several attempts. Metrics can count logical actions, attempts, duplicate suppressions, unknown outcomes, compensation attempts, and unresolved records without confusing retries with new user intent.

Test the failure windows, not just the happy path

Unit tests around a tool function are not enough. Inject failure at the points where ownership or certainty changes.

  1. Send the same key and arguments concurrently from two workers. Only one side effect should occur.
  2. Send the same key with different arguments. The system should reject it.
  3. Commit the external side effect, then drop the response. Reconciliation should find the result without repeating the effect.
  4. Crash the worker after it marks the action executing. A lease expiry should make the record recoverable.
  5. Replay a workflow from a checkpoint before the tool node. The action record should return the existing result.
  6. Fail the third step of a multi-step workflow. Completed compensations should run once and remain auditable.
  7. Retry the compensation after a timeout. It should not compensate twice.

Add invariants that production monitoring can check:

one tenant + one idempotency key -> one argument hash
one logical action -> zero or one successful external effect
succeeded -> immutable result identity
compensated -> linked original action and compensation action
unknown -> visible reconciliation owner and next check time

Start with the ambiguous timeout test. It exercises the gap between an external commit and the caller receiving its response, which is where duplicate effects usually enter the system.

Common implementation mistakes

Putting the key only in memory means it disappears on restart. Recording it after the external call leaves a crash window where the effect exists but the deduplication record does not. Generating a fresh key per attempt converts retries into new actions. Treating every error as retryable repeats permanent failures. Treating local checkpoint persistence as proof of external completion confuses two different systems.

Another mistake is returning a cached success for a reused key without checking the arguments. The caller can believe a new request succeeded even though the stored result belongs to an older request. Bind identity and arguments together.

Do not hide unknown outcomes. Pretending they are failures encourages duplicate retries. Give each one a reconciliation path and an owner.

What to do next

Pick one irreversible tool in your agent and trace every failure window around it. Add a durable action record before the call, pass one idempotency key through every retry, and write a test that drops the response after the external system commits. Do not give the agent broader write access until that test passes.

References

Be first in line.

Join the waitlist and we'll email you the moment it's ready. No sales call.

Try the demo →
or talk to us