Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

Idempotency and retries

Replay an invocation safely with an Idempotency-Key, and understand why Anlyon will not silently retry a side effect it cannot confirm.

Two different things get confused here, so this page separates them:

  • Idempotency is how you retry safely. You control it, with a key.
  • Automatic retry is something Anlyon does for delivery and deliberately does not do for an action invocation.

Idempotency keys

Send an Idempotency-Key header on a mutation and the key is claimed atomically before the handler runs, so concurrent retries perform the side effect at most once.

const { data } = await anlyon.actions.invoke(
  'refund-order',
  { charge: 'ch_3P9x', amount: 12000 },
  { idempotencyKey: `refund:${orderId}` },
);
result = anlyon.actions.invoke(
    "refund-order",
    {"charge": "ch_3P9x", "amount": 12000},
    idempotency_key=f"refund:{order_id}",
)

For operations other than action invocations, the cached-response rules are:

Situation What happens
Same key, same request The original response replays, with an X-Idempotent-Replay: true header.
Same key, different request 409 with error code idempotency_key_reuse. “Same request” means the same method, path, workspace, environment, credential and JSON body.
Duplicate arrives while the first is still in flight It waits, then replays. If the first does not finish in time, the duplicate gets 409 idempotency_request_in_progress.
The original request failed The key is freed, so you can retry it. Only successful responses are stored.
More than 24 hours later Stored responses are kept for 24 hours. For most operations the key is then unknown again. Action invocations are the exception: see below.

Choose a key derived from the operation, not from the attempt: refund:ord_42 is the point, and refund:${Date.now()} defeats it.

From @anlyonhq/sdk 2.2.0 and anlyon 0.5.0 the SDK sends an idempotency key on every invoke and generates one when you do not pass one. A resend is answered from the recorded invocation. Earlier versions send a key only when you pass one, so pass one. A generated key covers one call to invoke. When your own code can call invoke twice for one business operation, pass your own key.

Action invocations keep their key

On POST /api/v2/actions/{ref}/invoke, the key is also recorded on the invocation itself and kept for as long as the invocation exists. For gated actions, invocation creation, approval admission and approval linkage commit together. A refused admission leaves no invocation or reserved key. The action-specific rules are:

Situation What happens
Same key, same request, any time later Answered from the invocation as it is now, with X-Idempotent-Replay: current-state: 200, or 202 while it still waits for approval. It is not executed, admitted or charged again.
The invocation failed, or is unknown The retry shows that outcome. The key is not freed: running the action again is a new decision. Resolve an unknown one first, then invoke with a new key and retryOf. See Resolving an unknown outcome.
The request was refused before an invocation existed (invalid input, action disabled, quota) Nothing was recorded, so the key is still unused.
Same key from another credential, or with a different body 409 idempotency_key_reuse, without revealing the invocation.

This holds for dashboard sessions as well as API keys and OAuth, and when the replay store has lost the key.

Operations that accept an idempotency key are marked in the API reference. Both SDKs accept one on every operation that supports it, and CI fails if that ever stops being true.

Provider idempotency on governed actions

The Idempotency-Key above is between you and Anlyon. A governed action also has an identity between Anlyon and the provider, and it differs by adapter.

Action Key Anlyon sends to the provider Replay of a lost write
stripe.refund The effect’s Idempotency-Key, persisted before the write. The adapter treats the key as usable for 24 hours. Offered inside 24 hours. After that a repeat could create a second refund.
resend.email_send The effect’s Idempotency-Key. As of 2026-10-03, Resend keeps keys for 24 hours and returns its original response for the same key and payload, sending nothing. Offered inside 24 hours. After that a repeat could send a second email.
github.file_update None. The adapter sends no idempotency key. It sends the reviewed blob SHA as a precondition instead. Not offered.
A declared action None. A declared action sends no provider idempotency key. Not offered. A replay is refused before any write.

A replay of a declared action answers 409 REPLAY_REFUSED with the reason replay_unsupported, and nothing is sent:

{ "success": false, "error": { "code": "REPLAY_REFUSED", "message": "Replay refused (replay_unsupported): ..." } }

Reconciliation of a declared action repeats the read-back, never the write.

On a governed action you can also pass an operationKey. Repeating the same operationKey with the same request returns the existing operation, and nothing is sent again. Reusing it with a different request is refused with 409 OPERATION_KEY_REUSE. When you pass none, the Idempotency-Key is used.

Why an invocation is not retried for you

When Anlyon dispatches an action and the request times out, or the socket dies after the request was written, the destination may have processed it. Anlyon records that as unknown rather than failed, and stops.

It would be easy to retry. It would also be how one refund becomes two.

So the contract is: Anlyon tells you what it knows, including when what it knows is “I cannot confirm this”, and you reconcile with the destination before deciding. An unknown invocation carries a message saying so. See Receipts and grades for how to read the statuses.

An idempotency key does not make Anlyon send the request again. A retry with the same key and the same request is answered from the recorded invocation, and nothing reaches the destination. To run a failed invocation again, invoke with a new key and retryOf. An unknown invocation must be resolved first. See Retrying on purpose.

Retries that do happen

Event deliveries to your webhook subscriptions retry with backoff, and a delivery that exhausts its retries lands in a dead letter queue. Those retries apply to delivery, not to action invocations.

The distinction is not an oversight in one direction or the other. A webhook delivery is safe to retry because the receiver is expected to be idempotent about it. A refund is not, and pretending otherwise would be the most expensive default we could ship.

How it behaves

  • Idempotency keys are the caller’s. Two different keys are two operations: two refunds of the same payment under two keys are two refunds. An impact limit bounds that.
  • An action invocation is sent once. When the outcome cannot be confirmed, the invocation is recorded unknown and you reconcile before retrying.
  • A declared action sends its write once. It sends no provider idempotency key. A replay is refused before anything is sent, and reconciliation repeats the read-back.
  • A provider key is usable for 24 hours. The Stripe and Resend adapters offer a same-key replay inside that window.
  • A GitHub file update is recovered by lookup. The adapter sends the reviewed blob SHA as a precondition, and reconciliation looks for the commit that carries the effect trailer.
  • A repeated key is answered from the recorded invocation. For an action with no declaration and no adapter, nothing is sent to the destination again.

Where to go next

Was this page helpful?