Idempotency & retries
Every operation carries an idempotency key. The first call claims it, runs, and records its outcome; a retry with the same key replays that outcome without running again.
“Idempotent” is a fancy word for “safe to repeat”: do it once or do it five times and you end up in the same place. Setting a switch to “on” is idempotent; pressing a toggle is not. That property is what lets a client resend a request it never got an answer to, without it taking effect twice.
Source src/ports.ts#L666IdempotencyStoresrc/economy.ts#L620runClaimed
Networks drop responses and processes crash mid-write. A caller that never hears back can’t know whether its operation applied, so it retries; the platform has to make that safe: a retried operation takes effect at most once.
Every operation carries an idempotencyKey. The key is how the system recognizes a retry and replays the original result instead of repeating the work.
The idea
The first time an operation arrives, the system claims its key, runs the operation, and records the outcome under that key. If the same key ever comes back, the recorded outcome is replayed verbatim. The operation does not run a second time.
A retry returns the same transaction the first call produced, marked as a duplicate rather than a fresh committed.
The key must name the action, not the attempt. A retry path that mints a fresh key each try defeats the guard — every attempt looks like a new order:
async function chargeWithRetry(economy: Economy, order: Order) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await economy.submit(
spend({
idempotencyKey: `key_${crypto.randomUUID()}`, // fresh key per attempt
actor: userActor(order.buyerId),
orderId: order.id,
buyerId: order.buyerId,
sku: order.sku,
price: order.price,
}),
);
} catch {
// a dropped response retries — as a brand-new operation, charging twice
}
}
}
The correct form mints the key once, from the order itself, and reuses it on every attempt. However many tries the network forces, the spend posts once:
const idempotencyKey = `ord_${order.id}`; // one key per order, minted once
const first = await economy.submit(
spend({ idempotencyKey, actor: userActor(order.buyerId), ...details }),
);
const retried = await economy.submit(
spend({ idempotencyKey, actor: userActor(order.buyerId), ...details }),
);
// first.status → "committed" · retried.status → "duplicate", same transaction id
Why it exists
Conservation and solvency only hold if each operation posts once. A double-applied top-up would issue credits twice against a single payment; a double-applied payout would pay a seller twice for one request. Idempotency is what lets a client retry freely, as it must over an unreliable network, without risking either.
It’s also what upgrades the write path from at-most-once to exactly-once: the atomic commit lands each attempt fully or not at all, and idempotent retries let the caller resend until one attempt commits. The money moves once, however many tries that takes.
How a key is claimed
Claiming is a database operation, so the database itself stops two requests with the same key from both proceeding. The mechanism differs by engine, but the contract is the same:
- Postgres takes a transaction-scoped advisory lock on the key. A second request with the same key waits for the first to finish, then reads and replays its recorded row.
- MySQL inserts a placeholder row to hold the key’s lock, then fills in the recorded result on commit. A second insert collides on the primary key.
Either way the key is a primary key, so a duplicate can never be written.
A rejected request leaves the key unused
Only a committed outcome records a result under the key. If the operation is rejected or throws, the transaction rolls back and the key is left unused.
A caller can retry a rejected request under the same key — a decline isn’t permanent. Only a successful commit is final.
The horizon is the host’s
Claims live forever by default. A host that opts into the retention sweep’s idempotencyOlderThanMs bounds that: a claim older than the horizon is deleted, and a duplicate request arriving after the deletion re-executes instead of replaying. The guarantee on this page holds exactly up to the horizon, which is why the horizon must exceed every window in which a caller could still retry.
What relies on it
- Every
submitruns through this guard, so the property is universal: no operation can be applied twice. - The
duplicateoutcome is the visible result of a replay: the same transaction, no new work. - Inbound provider webhooks carry their own, separate dedup guard keyed on the webhook’s event id, kept apart from this table so the two layers can’t collide on a shared key.
Try it
The guard is live on this page. The snippet below is a real file, type-checked against the engine and run here against the same build the console drives:
import { credits, spend, systemActor, topUp, userActor } from '@pwngh/economy-lab';
import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// One operation, submitted twice. Mint the key once from the order and reuse it on
// every retry: the first spend commits and posts a transaction; the replay returns that
// same transaction as duplicate, so the buyer is charged exactly once. Each run funds
// and places one fresh order.
export async function run(economy: Economy): Promise<SnippetReport> {
await economy.submit(
topUp({
idempotencyKey: `idem_${crypto.randomUUID().slice(0, 8)}`,
actor: systemActor('docs'),
userId: 'usr_alice',
amount: credits(120),
source: 'card',
}),
);
const orderId = `ord_${crypto.randomUUID().slice(0, 8)}`;
const order = spend({
idempotencyKey: orderId, // one key per order, minted once
actor: userActor('usr_alice'),
orderId,
buyerId: 'usr_alice',
sku: 'Docs Demo Pass',
price: credits(120),
recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
});
const first = await economy.submit(order);
const again = await economy.submit(order);
return {
lines: [
`first: ${first.status} → ${first.status === 'committed' ? first.transaction.id : '—'}`,
`again: ${again.status} — same transaction, nothing new posted`,
],
txnId: first.status === 'committed' ? first.transaction.id : undefined,
};
}The committed posting is then waiting in the console’s ledger — the console replays this page’s operations on entry, so the drill opens on the very transaction the snippet created.
Recap
- Every operation carries an
idempotencyKey. The first call claims it, runs, and records its outcome. - A retry with the same key replays the recorded outcome as
duplicate— the operation never runs twice. - Only a committed outcome consumes the key; a rejected request can retry under the same key.
- The key must name the action, not the attempt.
Now put the claims to work.
Challenge: make the retry harmless
This checkout retries when a response goes missing — as it must — but every attempt rebuilds the order from the cart, fresh ids and all. Run it and watch the buyer pay twice. Then click into the code and make the retry harmless.
import { credits, spend, systemActor, topUp, userActor } from '@pwngh/economy-lab';
import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// A checkout with a retry path: the network dropped the first response, so the client
// sends the order again, rebuilding the request from the cart each time.
export async function run(economy: Economy): Promise<SnippetReport> {
const buyerId = `usr_retry_${crypto.randomUUID().slice(0, 6)}`;
await economy.submit(
topUp({
idempotencyKey: `idem_${buyerId}`,
actor: systemActor('docs'),
userId: buyerId,
amount: credits(200),
source: 'card',
}),
);
const attempt = () =>
economy.submit(
spend({
idempotencyKey: `key_${crypto.randomUUID()}`, // minted per attempt
actor: userActor(buyerId),
orderId: `ord_${crypto.randomUUID().slice(0, 8)}`, // rebuilt per attempt
buyerId,
sku: 'Starter Pack',
price: credits(100),
recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
}),
);
const first = await attempt();
const retry = await attempt(); // the response was lost — send it again
const committed = [first, retry].filter((o) => o.status === 'committed').length;
const verdict = committed === 1 ? 'one cart, one charge' : 'the buyer paid twice for one cart';
return {
lines: [
`first: ${first.status} · retry: ${retry.status}`,
`committed: ${committed} — ${verdict}`,
],
};
}Hint
What should two attempts at the same order share?
Solution
Mint the order's identity once, outside the retry path, and reuse both the order id and the key on every attempt. The first attempt commits; every retry replays that recorded outcome as duplicate, and the buyer is charged exactly once — however many tries the network forces.
import { credits, spend, systemActor, topUp, userActor } from '@pwngh/economy-lab';
import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// The fix: the order's identity is minted once, from the cart, and every attempt reuses
// it. The first attempt commits; the retry replays that recorded outcome as `duplicate`
// — same transaction, nothing new posted.
export async function run(economy: Economy): Promise<SnippetReport> {
const buyerId = `usr_retry_${crypto.randomUUID().slice(0, 6)}`;
await economy.submit(
topUp({
idempotencyKey: `idem_${buyerId}`,
actor: systemActor('docs'),
userId: buyerId,
amount: credits(200),
source: 'card',
}),
);
const orderId = `ord_${crypto.randomUUID().slice(0, 8)}`; // minted once
const attempt = () =>
economy.submit(
spend({
idempotencyKey: orderId, // every attempt shares the order's key
actor: userActor(buyerId),
orderId,
buyerId,
sku: 'Starter Pack',
price: credits(100),
recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
}),
);
const first = await attempt();
const retry = await attempt(); // the response was lost — send it again
const committed = [first, retry].filter((o) => o.status === 'committed').length;
const verdict = committed === 1 ? 'one cart, one charge' : 'the buyer paid twice for one cart';
return {
lines: [
`first: ${first.status} · retry: ${retry.status}`,
`committed: ${committed} — ${verdict}`,
],
};
}