Skip to content

Idempotency is a user feature

A user asked for a daily reminder. The provider timed out after the scheduling tool had inserted the row. The worker retried the turn, and the model called the tool again. Two reminders fired the next morning. The database was internally consistent, the retry policy behaved as configured, and the product was still wrong.

Users experience idempotency as "the system did what I asked once." They should not need to understand delivery semantics to avoid duplicates.

Where duplicate work enters

text
user click
   |
   +--> client retry --------+
   |                         |
   +--> worker retry --------+--> same intended effect
   |                         |
   +--> provider repeat -----+
   |                         |
   +--> resume after pause --+

Each layer can repeat an action for a valid reason. The receiving boundary must recognize the same intent.

Terminology

  • Idempotent operation: Repeating it with the same identity produces no additional effect.
  • Idempotency key: A stable identifier for one intended effect.
  • Natural key: Existing domain fields that uniquely identify the effect.
  • No-op: A successful repeat that changes nothing.
  • Deduplication window: The period during which keys remain recognizable.
  • Exactly once: A user-visible guarantee assembled from durable identities and idempotent effects, not a property of ordinary network delivery.

Put identity at every write boundary

The browser should generate a request ID before sending. That ID travels through optimistic local storage, synchronization, and the server mutation. A retry uses the same ID.

The server inserts the message and outbox row conditionally. The worker derives a stable logical-turn ID from claimed outbox IDs. Each tool call gets an operation ID from the provider protocol or the harness.

For a side-effecting tool, use that operation ID as the idempotency key:

ts
async function createSchedule(
  db: Db,
  operationId: string,
  input: ScheduleInput
) {
  const existing = await db.oneOrNone(
    `SELECT * FROM schedules WHERE created_by_operation_id = $1`,
    [operationId]
  );
  if (existing) return existing;

  return db.one(
    `INSERT INTO schedules
       (id, instruction, next_run_at, created_by_operation_id)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (created_by_operation_id)
     DO UPDATE SET created_by_operation_id = EXCLUDED.created_by_operation_id
     RETURNING *`,
    [crypto.randomUUID(), input.instruction, input.nextRunAt, operationId]
  );
}

The no-op update is one way to return the existing row in Postgres. A transaction with INSERT ... ON CONFLICT DO NOTHING followed by a select is also clear.

The key must represent intended identity. A random key generated inside the tool on each attempt does nothing. A hash of mutable display text may incorrectly merge two deliberate actions.

Choose the right identity scope

There is no universal idempotency key. Match scope to the effect.

For a message send, the client request ID identifies one user submission.

For a tool call inside an automatic retry, the operation ID identifies one requested side effect. This assumes the retry reconstructs the same operation identity. If the provider generates fresh tool-call IDs on every attempt, derive a stable application key from the logical turn, tool name, and canonical arguments.

For a publish action, the pair (resource_id, revision) is often the best key. Publishing revision 12 twice should return the same live result. Publishing revision 13 is a new effect.

For a scheduled run, (schedule_id, occurrence_time) identifies one firing. The scheduler may claim that occurrence more than once after a crash, but only one result row should exist.

For outbound delivery, (message_id, channel, target) prevents duplicate sends at the application row level. The remote provider should also receive its supported idempotency key where available.

Canonicalize before hashing

JSON object key order, omitted defaults, and normalized URLs can produce different bytes for the same request. Define a canonical form:

ts
function canonicalAction(input: {
  recipient: string;
  subject: string;
  body: string;
}) {
  return JSON.stringify({
    recipient: input.recipient.trim().toLowerCase(),
    subject: input.subject.trim(),
    body: input.body.replace(/\r\n/g, "\n"),
  });
}

function actionKey(logicalTurnId: string, tool: string, input: unknown) {
  return sha256(`${logicalTurnId}\n${tool}\n${stableJson(input)}`);
}

Canonicalization is policy. Lowercasing an email address is usually acceptable. Lowercasing an arbitrary case-sensitive account ID is not. Test the domain rules.

Approval hashes need the same discipline. The approved input and the executed input must canonicalize identically. If the tool applies defaults after approval, include those defaults in the approved representation.

Idempotency in verification and deployment

Agent systems often spend quota on checks, builds, and deploys. These are side effects too.

Cache a verification result by:

  • immutable draft revision;
  • flow fingerprint;
  • checker version.

If none changed, return the stored result. If only the flow changed, reuse the same instrumented draft deployment when safe. If code changed, create a new revision identity.

Publishing should record the operation key and live revision. A repeated publish of the already-live revision becomes a no-op. This saves quota and makes "try again" safe for the user.

Derived files need byte stability. Regenerating a manifest or service worker from unchanged inputs should produce identical bytes and no revision bump. Timestamps in generated files destroy idempotency unless the timestamp is part of the required output.

Decisions and trade-offs

Store keys durably. An in-memory cache handles double-clicks until a restart, which is precisely when retries matter most.

Return the original result on a repeat. A generic "already processed" error makes retry recovery awkward. The caller needs the schedule ID, deployment URL, or delivery status from the first attempt.

Keep keys as long as the effect can matter. Deleting keys after a day is unsafe for a weekly schedule or a delayed provider retry. Natural keys tied to durable rows avoid arbitrary windows.

Do not promise universal exactly-once delivery. A remote email service can accept a request and drop the connection before responding. Without provider idempotency, the sender must choose between possible duplicate and possible omission. Expose uncertainty where it exists.

Make no-op success visible in diagnostics, quiet in the UI. Operators need to count deduplicated repeats. Users usually need the original successful result, not a warning.

Failure modes

The tool may write the effect and crash before storing the key. Prevent this by storing the effect and key in one database transaction. For external calls, insert a durable command row first, then let a delivery worker handle provider uncertainty.

Two workers can check for an existing key at the same time and both act. A unique database constraint must enforce the decision. An application-level select is only an optimization.

An idempotency key can be too broad. Using (user_id, "send_email") would suppress every later email. Include the smallest fields that identify one intended effect.

A key can be too narrow. Including the attempt number guarantees every retry looks new. Keep attempt identity out of effect identity.

A check cache can outlive its assumptions. Include checker version and every relevant configuration fingerprint. Otherwise a newly added security rule may never run on unchanged code.

Field checklist

The protocols in Tools are protocols become safe to repeat once their writes have stable identities. Work that spans minutes, waits for approval, or resumes tomorrow needs another mechanism. Continue with Long-running work is a state machine.

Built from field notes on durable software systems.