ai newsDi., 27. Okt.
EilmeldungHow to Use Gemini CLI in 2026: Install It, Wire It Up, and Ship Your First Edits

73 comments later: what we changed so a retrying AI agent doesn't pay twice

Last week I wrote about how we stop an AI agent from paying twice when it retries a paid API call . It got 309 views and 73 comments from 11 people, and most of the comments found a real gap. This is the follow-up: what each gap was, what we changed, and what is still on the list. Everything below

dev.toDienstag 06:27 · 27.10.4 Min. Lesezeit
Bild: dev.to

Last week I wrote about how we stop an AI agent from paying twice when it retries a paid API call. It got 309 views and 73 comments from 11 people, and most of the comments found a real gap.

This is the follow-up: what each gap was, what we changed, and what is still on the list. Everything below is live on our gateway unless I say otherwise.

Lin pointed out that many agent runtimes treat any 409 as terminal. Our payment_in_flight answer said "retry shortly" in prose and nothing else, so a runtime would give up and mint a fresh payment id on the next step.

The in-flight 409 now carries the fields a program can act on:

The agent guide says it plainly: payment_in_flight is not final. Wait retry_after seconds and send the same id again, or read the order.

Samii asked what happens when the first request dies outright. I checked, and it was worse than I thought. If our process died mid-request, the payment id stayed "in flight" until its 24 hours ran out. The reconcile job would settle the order from the chain, but the id row never learned about it. A retry could keep getting a 409 for an order that was already paid.

Now an in-flight row older than 180 seconds is resolved from the order itself. 180 is longer than the 120 second x402 payment timeout, so a first attempt that is still alive is never pre-empted. If the order is paid, the retry gets the stored order back with replayed: true. If it is still paying, the retry gets the 409 above. The row heals on the next request instead of waiting a day.

Robert's point was that a conflict and an in-flight request mean different things to the client. One says the intent changed, the other says wait. They already had separate codes, but the conflict didn't say why. It now carries reason: payer or reason: request, and we alert on conflicts only. A plain repeat of the same request is normal and stays quiet.

Launchgatecheck named the assumption under all four server layers: the client can present the same id after a crash. That only holds if the agent stored the id before it sent anything.

The docs now say this as a rule. Store the intent first: the payment id, the quote and the recipient, plus the signed authorization itself. Then send. After a restart, send the same id again and never sign a new authorization for the same quote. The two stored things protect different things. The signed authorization bounds the money, because the single-use nonce fails on chain if it is replayed. The id recovers the work, because it is what gets the original result back. The guide also asks tool adapters to refuse to send a payment that has no stored intent.

This one came out of a long thread with Robert. Most of our tools are lookups. Over x402 a duplicated lookup was charged in full, even when the answer came from our cache, because the cache discount only existed on the prepaid balance.

Now, on x402, the same wallet asking the same cacheable lookup again within 120 seconds gets the answer without a second charge. The response carries X-APIbase-Charged: false and the receipt of the original paid call. It is deliberately narrow. It covers reads only, never writes, because two identical orders a minute apart can be exactly what the user wanted. Shop tools and the MPP rail are out of scope. The wallet is the session, so this assumes one wallet per agent, which is what our buyer docs already recommend.

Samii warned about volatile fields: a timestamp or cursor in the arguments makes every retry look new. A tool can now mark such fields so they are left out of the key. Nothing is left out by default, so the worst case of a missing mark is a missed match, not a wrong answer.

The longest thread, with Kami, ended on an asymmetry. On Tempo, money moves before the work runs, so we had built a refund-owed record for failed or duplicate calls. On Base we settle only after the result is ready, so nobody had needed one. The edge case that broke this was a duplicate transfer landing after a failed settle. It would only have been found if someone looked.

There is now one refund-owed ledger for both rails. It records the two cases that matter here, a payment that arrived after the order was already final and a duplicate payment. The refund state shows up on the public receipt and in the order lookup, so the payer can see it.

These are designed and queued, not live yet:

A workflow-level id would merge two honest identical lookups in the same run, so the id stays per tool call. Writes never collapse on what the request looks like, only on an explicit key the agent minted. Per-session limits are easy to dodge because the server sees wallets, not sessions, so the hard boundary stays where Yamrail and I ended up: the wallet the agent pays from, with a cap on it.

Thanks to everyone who commented. Each change above started in that thread, so if you see a gap in this version too, I would like to hear it.

Quelle: dev.to (dev.to). Original öffnen