Skip to main content
Technical Tutorials

How to test duplicate-safe retries in an AI workflow

Run a Node.js fixture that drops the first acknowledgement after a destination write, replays the same request ID, rejects changed parameters, and checks the business-state count.

Sean McLellan profile photo

Sean McLellan

Lead Architect & Founder

10 min read

An AI workflow can receive a timeout after a destination has already accepted its write. If the workflow submits the action again without a stable request identity and a destination-side duplicate check, the second attempt can create another record, send another message, or publish the same change twice.

This tutorial builds and runs a small Node.js fixture that completes one destination write, drops its acknowledgement, and retries the same business request. The test proves that two workflow attempts caused one destination action. It also rejects changed parameters under the same key and shows where an in-memory teaching fixture stops being useful.

The failure happens after the destination changes

An acknowledgement is the response that tells the caller a destination accepted or completed a request. A lost acknowledgement leaves the caller with an uncertain result. The destination may have changed even though the workflow recorded a timeout or connection error.

AWS uses this failure in its Builders' Library guidance on idempotent APIs. A resource-creation request can reach the service and complete while the response fails to return. Retrying the same operation can create a second resource unless the API recognizes that both attempts express one intent.

Idempotency means that repeating the same logical request does not add another side effect. For this test, the business promise is exact: the workflow may attempt the write twice, but the destination action count must remain one.

A successful second response does not prove that promise. The test must inspect the business state at the destination.

Give one business action one request ID

The caller creates the request ID before the first attempt and reuses it only for retries of the same intended action. The fixture uses readable IDs such as case-104:publish-summary:v1 so the relationship is visible during the test. Production systems often use UUIDs or another collision-resistant identifier and store the business reference separately.

Do not derive duplicate identity only from matching parameters. AWS explains why identical inputs can still represent two intended resources. A caller-provided request ID lets the caller state whether two attempts belong to one action.

The fixture also hashes the parameters. If a later call reuses the request ID with changed input, the service rejects it. This prevents one key from silently changing meaning.

Stripe documents idempotent requests for its own API. Stripe saves the first request's status and body, returns the stored result for later requests with the same key, and errors when the parameters differ. Those are Stripe behaviors. The fixture below uses similar rules for teaching and does not define a contract for other APIs.

Download and run the Node.js fixture

Download the complete source and executed output into one directory:

The publication run used JavaScript ECMAScript modules on Node.js v24.16.0. It uses the built-in node:test, node:assert/strict, and node:crypto modules. It has no third-party package dependency. pnpm 10.28.1 was present in the repository, but the test command does not use pnpm.

From the directory containing both downloaded .mjs files, run:

node --version
pnpm --version
node --test duplicate-safe-retries.node-test.mjs

The fixture has two stores. DestinationStore represents the business system that receives the action. IdempotentActionService keeps the parameter fingerprint and response under the caller's request ID.

The failure injection runs after both in-memory writes:

const response = this.#destination.create({ requestId, input });
this.#records.set(requestId, {
  createdAt: this.#clock(),
  fingerprint: inputFingerprint,
  response,
});

if (this.#dropAfterWrite.delete(requestId)) {
  throw new LostAcknowledgementError(requestId);
}

The workflow sees an error, but the destination count and stored result already changed. This ordering reproduces the failure that a happy-path test misses.

The first test then retries with the same ID and parameters:

service.dropNextAcknowledgementFor(requestId);

await assert.rejects(
  service.execute({ requestId, input }),
  error =>
    error instanceof LostAcknowledgementError &&
    error.code === "LOST_ACKNOWLEDGEMENT"
);

const replay = await service.execute({ requestId, input });
const acceptedAction = destination.findByRequestId(requestId);

assert.equal(service.attemptsFor(requestId), 2);
assert.equal(destination.count, 1);
assert.equal(replay.replayed, true);
assert.deepEqual(replay.response, acceptedAction.response);

These assertions have separate jobs. attemptsFor(requestId) === 2 proves that the workflow made the first call and a retry. destination.count === 1 proves the business action happened once. The deep equality check proves that the retry recovered the stored result associated with that request ID.

Reject the same key when the parameters change

A request ID belongs to one intent. Reusing it for another status, amount, recipient, content body, or target can make a duplicate check hide a new action or apply old evidence to changed work.

The second test completes one request and then changes status from approved to rejected while keeping the same ID:

await service.execute({
  requestId,
  input: { caseId: "case-105", status: "approved" },
});

await assert.rejects(
  service.execute({
    requestId,
    input: { caseId: "case-105", status: "rejected" },
  }),
  error =>
    error instanceof IdempotencyMismatchError &&
    error.code === "IDEMPOTENCY_PARAMETER_MISMATCH"
);

assert.equal(service.attemptsFor(requestId), 2);
assert.equal(destination.count, 1);

The mismatch must stop before another destination write. The destination count remains one.

The executed assertions show two attempts and one action

The publication run produced these evidence lines:

EVIDENCE same_key_replay {"attempts":2,"destinationCount":1,"replayed":true,"sameStoredResult":true}
EVIDENCE parameter_mismatch {"attempts":2,"destinationCount":1,"mismatchRejected":true}
EVIDENCE same_process_concurrency {"attempts":2,"destinationCount":1,"oneFreshAndOneReplay":true}
EVIDENCE key_expiry {"attempts":2,"destinationCount":2,"freshActionAfterExpiry":true}
EVIDENCE unknown_state_reconciliation {"destinationCount":1,"reconciliationState":"confirmed","retryReplayed":true}

All five tests passed. The first line is the central proof: two attempts, one destination action, a replayed result, and equality with the stored response. The second line shows that changed parameters did not create another action. The complete TAP summary and run timings remain available in the downloadable test output.

The sequence can be read as one state table:

Scroll sideways to see all 4 columns.

StepRequest IDWhat the caller observesDestination action count
First attempt startscase-104:publish-summary:v1Waiting for a response0
Destination accepts and stores the resultSame IDNo response yet1
Fixture drops the acknowledgementSame IDLOST_ACKNOWLEDGEMENT1
Workflow retries the same intentSame IDStored result returned1
Test checks business stateSame IDTwo attempts recorded1

That count is the acceptance condition. A test that checks only the retry response can pass while the destination contains two actions.

Concurrency needs a shared durable guard

The fixture includes two simultaneous calls with the same request ID. An in-process promise map lets the second call wait for the first, then return the stored result. The test records two attempts and one destination action.

This result applies to one Node.js process. Another process, host, container, or region cannot see that promise map. A restart removes it. Production code needs a guard shared by every writer, such as a destination API that enforces idempotency or a durable store with an appropriate unique constraint and transactional write path.

AWS states that the idempotency record and mutating operations must have an atomic, durable server-side boundary. The fixture writes two maps synchronously and does not inject a process crash between them. It cannot establish the AWS production property or make a local record atomic with an external API write.

Test the real coordination boundary with concurrent requests from separate workers. Inspect both the idempotency records and the destination count after the race.

Key expiry defines the end of duplicate protection

The fixture requires an explicit positive retention period. Its expiry test performs one action, advances a deterministic clock beyond that period, and sends the same key again. After expiry, the same request ID can create another destination action. The destination count becomes two.

This is a deliberate failing boundary for the business promise. The system must retain duplicate identity longer than any legitimate retry, delayed delivery, queue replay, or reconciliation window. It must also define what happens when an old key returns.

Stripe says its keys can be pruned after they are at least 24 hours old and that reuse after pruning starts a new request. That is Stripe's published policy. Choose retention from the contract of the destination and the timing of the local workflow. Do not copy a 24-hour period into another system without checking both.

Reconcile an unknown destination state before another write

A local idempotency record can be missing while the destination has already changed. This can happen when the destination accepts a request and the caller loses both the response and its local completion record. A blind retry is unsafe unless the destination itself enforces the same request ID.

The safe rule is to reconcile the destination state before another write. The fixture demonstrates a bounded reconciliation path. The test arranges an accepted destination action with no local record. It then looks up the action by request ID, checks the parameter fingerprint, rebuilds the local record, and lets the next same-key call replay the confirmed result. The destination count remains one.

That lookup is available because the teaching destination stores the request ID. A production destination may provide an idempotency lookup, resource query, provider message ID, transaction reference, audit event, or customer-visible state. If it provides none of these, the workflow cannot turn uncertainty into proof. Hold the case for a named operator instead of guessing.

Use the AI workflow case record to store the destination check, the last confirmed boundary, the next safe transition, and who may authorize another attempt. The case record already treats Unknown — reconcile before retry as a distinct destination state.

Workflow retries and destination idempotency are separate controls

Temporal's Activity documentation says Activities contain business logic that can be retried, and Temporal recommends designing them to be idempotent. An Activity retry can repeat your function. It cannot change the contract of the external API that the function calls.

Pass the same business request ID through every Activity attempt. Confirm that the destination honors it or place the durable duplicate check at a boundary shared by all callers. Keep the workflow attempt ID and the business request ID separate because one logical action can have several execution attempts.

This distinction also matters outside Temporal. A queue retry policy, agent tool retry, HTTP client retry, and scheduler retry can all repeat a call. None of them proves that the destination changed once.

Save the evidence that controls the next retry

For each write-capable workflow action, retain the business request ID, parameter fingerprint or immutable intent reference, attempt IDs, destination response, destination confirmation or read-back, final business state, retention boundary, and retry decision. Record the model, tool, and workflow version when those details help reproduce the run.

The AI agent receipt template provides the broader run record: trigger, source data, proposed action, policy boundary, reviewer decision, final action, destination, final-state verification, and correction path. The AI workflow controls guide connects that record to permission, tests, monitoring, escalation, and rollback.

Duplicate prevention does not remove the need for recovery. A request can be unique and still be wrong. Use the AI workflow rollback plan to name the stop trigger, affected system, repair action, owner, notification, and re-enable condition.

BaristaLabs uses process automation and integration to test these boundaries on a real handoff. If one workflow can create, send, charge, update, or publish, review its retry path before it receives more permission.

Source note

AWS controls the lost-response example, caller-provided request-ID pattern, semantically equivalent replay concept, late-request concern, changed-intent concern, and requirement for an atomic and durable server-side boundary. Stripe controls only the stated behavior of Stripe's API, including stored status and body, changed-parameter rejection, key guidance, and retention behavior. Temporal controls the statement that Activities can be retried and should be idempotent.

The fixture, tests, request-ID examples, parameter fingerprint, evidence lines, state table, and operating recommendations are BaristaLabs teaching material. The in-memory maps do not prove durability, multi-process coordination, production concurrency, crash recovery, or compatibility with a specific external destination.

Retry-path review

Test one real write before the workflow gets more permission

BaristaLabs can help your team inject a lost response, verify destination state, define the durable duplicate check, and record the retry and reconciliation path for one workflow action.

Best fit when an AI workflow can create, send, charge, update, or publish and the destination may accept a request before the caller receives a response.

Turn this idea into a pilot

Which workflow should go first?

Use the readiness check to compare impact, effort, risk, owner, and next step before booking a call.

  • 3-5 minutes
  • Deterministic score
  • No sensitive data
Check workflow readiness

Practical AI Workflow Notes

Want more practical AI operations ideas?

Get short notes on applying AI inside real small-business workflows — from document handling and customer follow-up to internal reporting, compliance, and automation guardrails.

A useful next step if you’re still exploring and not ready to book a 20-minute AI assessment.

Occasional emails. Practical workflow guidance only. Unsubscribe anytime.