hamsa software
← All articles
Integrations8 min read

One purchase, several attempts

A retry key is a promise about a particular purchase. A worked request ledger and tested failure cases show what has to remain true when calls overlap, answers disappear and the payload changes.

A continuous looping ribbon crosses its own path and ends near a small circle, suggesting repeated attempts tied to one purchase.

Before the second click

The supplier may already have the order when the screen offers you Retry. That is the awkward part of a timeout: it describes the conversation, not necessarily the purchase. You still need the delivery, so trying again is reasonable. But the application needs to know whether that click asks about the purchase already submitted or asks the supplier to create another one, even when the quantities and delivery details are identical.

Consider a wholesaler ordering five filters. The supplier saves the order, but the response never reaches the wholesaler. The caller knows it lacks confirmation; the supplier knows it accepted an order. Neither fact contradicts the other. Making the timeout shorter would reveal the uncertainty sooner without resolving it. The purchase needs an identity that survives the failed conversation.

Give that purchase a saved request key, K1, before sending it. The invented account A7 uses the operation create_order with {sku: FILTER-5, quantity: 5}. If confirmation disappears, its next attempt brings K1 back. A new connection has not changed what the wholesaler wanted to buy. The small records in the accompanying example let us follow that same purchase without confusing it with another deliberately identical order.

AWS’s discussion of idempotent APIs explains why a caller-provided identifier is preferable to guessing intent from matching parameters [1]. Two identical purchases may both be wanted. In this example, repeating K1 asks for the outcome of the original purchase; deliberately creating K2 expresses another purchase. That distinction belongs in the application’s behaviour, because the operator cannot reliably recover it from a timeout message.

What the receiver must remember

The supplier side needs a request ledger as well as an orders table. In our example, its unique lookup is (account, operation, request_key). Account comes from the authenticated context, not an arbitrary account field the caller can impersonate. Scoping the key also means account A8 can use its own K1 without accidentally receiving A7’s result.

The ledger stores the normalised payload, processing state and resulting order identifier. For this deliberately tiny contract, only a non-empty SKU and positive integer quantity are accepted. JSON fields are sorted and written consistently before comparison, so changing property order alone does not create a conflict. Real contracts need equally explicit decisions about defaults, units and every field that changes the purchase’s meaning.

A hash can make a payload comparison compact, but it does not define which differences matter. Omitting the delivery destination from the comparison would let a changed destination masquerade as the original request. Keeping a normalised representation also costs storage and may retain sensitive information. Store the evidence needed for the contract under an appropriate access and retention policy, rather than automatically preserving every incoming header and body.

After acceptance, the supplier must remember which order belongs to K1. Here it is order 1, recorded with the five-filter payload and state completed. Returning only that the key already exists would prevent another order while leaving the person arranging delivery with nothing to track. Returning the original result lets them continue the purchase they actually made. The schema below keeps that relationship explicit.

sql
-- Simplified schema used by the local SQLite model.
CREATE TABLE orders (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  account TEXT NOT NULL,
  sku TEXT NOT NULL,
  quantity INTEGER NOT NULL CHECK (quantity > 0)
);
CREATE TABLE request_ledger (
  account TEXT NOT NULL,
  operation TEXT NOT NULL,
  request_key TEXT NOT NULL,
  payload_json TEXT NOT NULL,
  state TEXT NOT NULL,
  order_id INTEGER REFERENCES orders(id),
  PRIMARY KEY (account, operation, request_key)
);

The boundary is the transaction

Now let two attempts for the same purchase reach the supplier before either has finished. Both can read that K1 is absent. If each saves an order first, the later ledger insert can reject the repeated key only after the wholesaler has acquired two orders. The uniqueness rule has worked, but too late for the purchase. The order and its remembered request therefore need to succeed or fail together.

The order write and ledger completion therefore belong in one atomic boundary when both are local. Claim the scoped key, create its order, attach that result and commit; an exception rolls the whole transaction back. The caller can then recover one accepted order. Telling a separate warehouse is another obligation, because this database commit cannot also commit work in an external service.

The SQLite example uses separate connections and BEGIN IMMEDIATE before looking up K1. SQLite allows one writer at a time [2], so the second attempt waits for the first decision. This also delays unrelated writes. A different database needs a suitable way to claim the key, but the purchase requirement stays the same: a competing caller must not create another order while ownership is being settled.

This short purchase finishes before committing, so the temporary working marker becomes completed inside that transaction. A caller never receives a durable half-finished row. A longer job might need to acknowledge work still in progress. It would then need an owner, expiry and recovery rules, so support can tell whether another worker may continue it. Merely adding the status would leave the original uncertainty unresolved.

What an in-progress ledger row would require

Two local transaction designs discussed in the purchase example; the second is outside the executed fixture.

Follow the labelled arrows. On a narrow screen, scroll sideways if needed.

What an in-progress ledger row would requireK1 inside one local transaction. The fixture never commits working separately; the order and its ledger change stay in the same transaction. Asynchronous job commits working. This alternative is outside the executed fixture. Another caller can observe durable in-progress work. Rollback removes both changes. The order write and ledger change disappear together if the local transaction fails. Define ownership, expiry and recovery. A durable working marker needs its own state machine before it can provide duplicate protection. From K1 inside one local transaction to Rollback removes both changes: Local transaction fails. From Asynchronous job commits working to Define ownership, expiry and recovery: Durable marker remains.LocaltransactionfailsDurable markerremainsK1 inside one localtransactionThe fixture never commitsworking separately; the orderand its ledger change stay inthe same transaction.Asynchronous jobcommits workingThis alternative is outsidethe executed fixture. Anothercaller can observe durablein-progress work.Rollback removes bothchangesThe order write and ledgerchange disappear together ifthe local transaction fails.Define ownership,expiry and recoveryA durable working markerneeds its own state machinebefore it can provideduplicate protection.
The fixture uses the first design. A durable working marker would introduce a different state machine.

Different attempts, different answers

The first local run creates order 1 and discards its return value. Repeating the five-filter purchase under K1 recovers order 1, with one order still stored. This simulates missing confirmation without a network failure. Two threads with separate connections also produced one created result and one replayed result. The ledger table keeps those local outcomes inspectable; neither caller needed to turn its missing answer into a new purchase.

Changing quantity to six while keeping K1 raises PayloadMismatch and leaves the original order unchanged. Silently updating order 1 would rewrite an already accepted intention. Silently creating another order would make a retry spend more stock. The application instead needs an explicit correction or cancellation process, or a new purchase when that is what the person means. A mismatch should expose the disagreement, not choose a commercial outcome.

Submitting the unchanged five-filter payload under K2 creates order 2. Identical contents are allowed because the caller deliberately expressed a second intention. Submitting K1 under account A8 creates a separate order too. These cases distinguish duplicate attempts from duplicate-looking business actions. A browser double-click should reuse the saved intention; a genuine repeat purchase should have a distinct confirmation route.

The failure tests matter as much as the successful ones. An injected exception after inserting the order but before ledger completion left zero orders and zero ledger rows after rollback; retry then created one order. The deliberately broken version committed two orders before the duplicate ledger insert failed. SQLite’s default constraint failure does not undo earlier committed work [3]. The observed difference explains why adding a unique key alone is insufficient.

Which request creates another order?

The normal sequence follows account A7 and its saved request keys. Failure controls use separate disposable databases. All records are illustrative; the listed outcomes were checked in the local model.

Scroll sideways to see all columns.

CaseAccount / keyQuantityOrders after caseResult
First request; answer discardedA7 / K151Created order 1
Repeat same intentionA7 / K151Replayed order 1
Change payload, reuse keyA7 / K161PayloadMismatch
New deliberate intentionA7 / K252Created order 2
Simultaneous repeats · separate fixtureA7 / K1 × 25 each1Created + replayed
Failure before commit · separate fixtureA7 / K150Order and key rolled back
Forget key · separate fixtureA7 / K1 again52New order: guarantee lost
The order and ledger must share a transaction. SQLite serialises these local writes; a remote fulfilment call needs a separate hand-off agreement.

Remembering has a time limit

There is an inconvenient limit to remembering a request: at some point, someone may delete the memory. A separate negative-control case removes K1 from the ledger while retaining its order, then submits K1 again. Another order is created. This is deliberately broken behaviour, not a cleanup recommendation. A unique business-order reference could provide another line of protection, but the model has no such rule. Its duplicate guarantee lasts only while the ledger, or another enforceable invariant, can connect the attempt to the original purchase.

The contract must therefore state its retention window and what happens outside it. Consider delayed queues, support replays, offline clients and an order that has since been cancelled. Decide whether an old request can return historical acceptance, current resource state, an expired-key response or a route to review. There is no useful universal duration to copy from an unrelated provider, and cancellation should not accidentally authorise a delayed request to recreate the purchase.

On the caller’s side, keep Awaiting confirmation distinct from Rejected and Confirmed. Save the original key and business reference so support can use an authorised lookup or repeat the original request within the contract. If retention has expired or the supplier offers neither safe repetition nor a trustworthy lookup, the remaining task is reconciliation with the supplier. Generating a fresh key simply because time passed would conceal that unresolved decision.

When this is more machinery than the job needs

A read-only search usually does not need a durable purchase-intention ledger. A simple local operation may already have a natural uniqueness rule, such as one booking per fixed slot. Those designs still need their own correctness checks, but importing this whole mechanism can add storage, contention and recovery work without addressing an actual repeated-effect risk.

For purchase creation, this machinery earns its place when repeat attempts are ordinary and repeat orders expensive. Agree the account and operation scope, meaningful payload differences, covered effects and retention window. Those are the promises support must explain after a missing answer. If acceptance also owes a warehouse instruction, keep that obligation visible separately rather than letting the returned order number imply that fulfilment has already happened.

Before deciding what Retry should do, follow the same five cases through the actual purchase contract: K1 again, simultaneous K1 attempts, a changed payload, a deliberate K2 and a K1 the service has forgotten. Check both the order left behind and the answer returned to the caller. The last case is especially revealing. If the service can no longer tell whether it remembers the purchase, the next step may be a conversation with the supplier. Giving the uncertainty a fresh key would only turn it into another order.

Working on a workflow like this? Hamsa helps finance and operations teams connect their tools and handle the exceptions, starting with one workflow.

See the Payment Operations Pilot

References & further reading

Primary sources behind this article. Living documentation may change.

  1. AWS Builders’ Library: Making retries safe with idempotent APIs
  2. SQLite: Transaction
  3. SQLite: The ON CONFLICT Clause
Hamsa Jama

Practical engineering. A human perspective.

More about Hamsa Software and the person behind it. Editorial notes.

A few more things
worth a read.

Back to the reading room
Integrations7 min read

A valid signature still needs a trust decision

An Oprindo sample had a matching asset hash and a valid signature, alongside an untrusted credential result. The useful story is in those separate answers.

Integrations6 min read

Should this old instruction still run?

Repairing a consumer makes replay possible. Current state, dependencies and recovery admission decide whether it is still appropriate.

Integrations7 min read

The backfill arrived after the live update

Historical work needs a population boundary, revision policy and crash-safe checkpoint strategy before its final page can mean done.