Idempotency & retry
An agent that retries commit_to_offer after an RPC timeout might commit twice. An agent that doesn't retry might never recover. Get this wrong and your customers get charged twice — or not at all.
Rule 1: every state-changing call has an idempotency key
Before calling, generate a deterministic key:
const key = `commit:${offerId}:${buyerAddress}:${intendedExchangeIdx}`
// or for ops without a natural key:
const key = randomUUID()Persist it before the call. After the call (or on retry), check whether it succeeded by:
- Looking up the key in your own state store.
- Or re-reading on-chain state (e.g.
get_exchangesfor the buyer + offer pair).
Don't fire the call until you've checked both.
Rule 2: when in doubt, re-read state
If a tool call fails or times out, you don't know whether the tx landed. Always re-read before retrying:
async function commitWithIdempotency(offerId, buyer) {
const before = await mcp.getExchanges({ where: { buyer, offer: offerId } })
const initialCount = before.length
try {
await commitFlow(offerId, buyer)
} catch (err) {
// Don't know if it succeeded — re-read.
await mcp.waitForIndexing({ blockNumber: latestBlock })
const after = await mcp.getExchanges({ where: { buyer, offer: offerId } })
if (after.length > initialCount) {
// It did succeed. Don't retry.
return after[after.length - 1]
}
throw err
}
}Rule 3: classify your errors
| Error class | Action |
|---|---|
| Network timeout (didn't reach server) | Safe to retry — call hasn't happened |
| RPC accepted but no receipt | Don't retry — re-read state first |
Tx reverted with known reason (e.g. OfferVoided) | Don't retry — it's a permanent failure |
Tx reverted with InvalidNonce | Re-fetch nonce, retry |
| Subgraph returned stale data | Wait for indexing, then retry the read, not the write |
Rule 4: exponential backoff with jitter
const delays = [1000, 2000, 4000, 8000, 16000].map(d => d + Math.random() * 1000)
for (const d of delays) {
try { return await tryOnce() }
catch (e) { if (!isRetriable(e)) throw e; await sleep(d) }
}
throw new Error("exhausted retries")Rule 5: persist before retrying
If your process dies between "tx broadcast" and "tx confirmed", you need to recover. Persist intermediate state:
preparing
MCP returns an unsigned tx.
signed
Wallet signs.
broadcast
RPC accepts; receipt pending.
confirmed
Receipt received.
indexed
Subgraph caught up.
done
Final state.
On restart, look up the last persisted state for each in-flight key and resume.
Common gotchas
- The subgraph lag is a footgun. Right after a successful commit,
getExchangesmay return empty for 1–3 blocks. Don't treat "empty" as "didn't happen" without waiting. InvalidNonceerrors don't mean failure of the original tx. They mean the local nonce is stale. Re-fetch and re-sign.- Idempotency keys must be unique per intended outcome, not per attempt. Two retries of the same intent share a key. Different intents (e.g. commits to different offers) get different keys.