Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Idempotency & retry · Boson Protocol
Skip to content

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:

  1. Looking up the key in your own state store.
  2. Or re-reading on-chain state (e.g. get_exchanges for 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 succeededre-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 classAction
Network timeout (didn't reach server)Safe to retry — call hasn't happened
RPC accepted but no receiptDon'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 InvalidNonceRe-fetch nonce, retry
Subgraph returned stale dataWait 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, getExchanges may return empty for 1–3 blocks. Don't treat "empty" as "didn't happen" without waiting.
  • InvalidNonce errors 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.

Next