Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./October 15, 2026/12 min

Your AI Agent Retries and Charges the Card Twice

Filed underai-testing/ai-agents/playwright/llm/cicd
Your AI Agent Retries and Charges the Card Twice

Table of Contents
  • Why does an AI agent duplicate a side effect it already completed?
  • Why doesn’t a fresh idempotency key fix it?
  • Derive the key from the tool call, not the handler
  • Guard the effect, don’t just store the key
  • How do you test exactly-once in Playwright?
  • Where should this test run, and what should it gate?
  • The one thing to do today

On this page

  • Why does an AI agent duplicate a side effect it already completed?
  • Why doesn’t a fresh idempotency key fix it?
  • Derive the key from the tool call, not the handler
  • Guard the effect, don’t just store the key
  • How do you test exactly-once in Playwright?
  • Where should this test run, and what should it gate?
  • The one thing to do today

Your payments agent called charge_card, the backend committed the charge, and then the gateway dropped the response on the floor. The agent saw a timeout, did the reasonable thing, and called charge_card again. Now the customer has two authorizations, your dispute queue has a new entry, and your eval suite is still green because every test you wrote assumed the tool either succeeds or fails — never both. The model didn’t hallucinate. It didn’t pick the wrong tool. It executed a correct side effect twice because nothing in your system enforces exactly-once.

This is the class of bug that survives contact with a “passing” agent. It doesn’t show up in golden-set accuracy, it doesn’t show up in a trajectory test, and it doesn’t show up until a real network blip lands in the two-hundred-millisecond window between commit and acknowledgment. I’ve watched a 0.3% gateway timeout rate on an anonymized fintech’s payments agent turn into 40-plus duplicate authorizations in its first week live — not because the model was bad, but because two independent retry layers both fired against a non-idempotent tool. Below is why that happens, why the obvious fix makes it worse, and the Playwright test that actually catches it.

Why does an AI agent duplicate a side effect it already completed?

Because a lost response is indistinguishable from a failed request, and an agent has two layers that retry on that ambiguity. The tool ran, the row committed, the money moved — but the caller never got the 200, so from the caller’s point of view nothing happened. Retrying is the correct behavior for a lost request. It’s a catastrophe for a lost response. The system can’t tell them apart from the outside.

The first retry layer is your model SDK. Both the OpenAI Python SDK and the Anthropic SDK automatically retry certain errors — connection errors, timeouts, 5xx — twice by default, before your code ever sees the failure. The second layer is the agent loop itself: when a tool result comes back as an error, a well-behaved agent is supposed to retry or fall back, which is exactly the recovery behavior you want it to have. Stack the two and a single dropped acknowledgment can produce three executions of a side effect the user asked for once.

§ Where the duplicate comes from

2

SDK auto-retries

Default in OpenAI + Anthropic SDKs

+1

Agent-loop retry

Correct recovery, wrong outcome

200ms

Commit→ack window

Where a blip becomes a double-charge

The uncomfortable part for anyone who came up through distributed systems: this is just the two generals problem wearing a language-model costume. Exactly-once delivery is impossible over an unreliable channel; the best you get is at-least-once delivery plus idempotent handling. Agents didn’t repeal that theorem. They just added a second, chattier client that retries with even less supervision than your old microservice did.

Why doesn’t a fresh idempotency key fix it?

Because most teams generate the key in the wrong place. The instinct is right — Stripe’s idempotency keys are the textbook fix: send a unique key with a mutating request, and the server returns the saved result of the first call instead of running it again (Stripe caches that result for 24 hours). The failure is subtler. Teams mint the key inside the tool handler, per invocation — so the retry generates a brand-new key, the server sees a request it’s never seen, and it happily charges again. You added idempotency infrastructure and got exactly zero idempotency.

Derive the key from the tool call, not the handler

The key has to be stable across every retry of the same logical intent, which means it can’t be born at call time. Derive it from something that survives the retry: the model’s tool_call.id, which is identical when the SDK replays the same response, hashed together with the arguments so a genuinely new intent gets a genuinely new key.

src/agent/idempotencyKey.ts
import { createHash } from 'node:crypto';
// Stable across SDK + agent retries of the SAME tool call.
// tool_call.id is constant when the model response is replayed;
// hashing the args guards against id reuse across turns.
export function idempotencyKey(toolCallId: string, args: unknown): string {
const canonical = JSON.stringify(args, Object.keys(args as object).sort());
return createHash('sha256')
.update(`${toolCallId}:${canonical}`)
.digest('hex')
.slice(0, 32);
}

Guard the effect, don’t just store the key

Then the handler has to actually use it as a guard, not just log it. First writer wins; every later caller with the same key reads back the stored result without re-running the effect.

src/agent/chargeCard.ts
export async function chargeCard(args: ChargeArgs, key: string, store: KeyStore) {
const seen = await store.get(key);
if (seen) return seen.result; // replay the first outcome, don't re-charge
const result = await gateway.charge(args); // the real, non-repeatable effect
await store.put(key, { result, at: Date.now() }); // commit BEFORE returning
return result;
}
[ WARNING ]

The ordering in the highlighted lines is the whole game. If you return the result before the store.put commits, two concurrent retries both read an empty store, both charge, and both write. Persist the key inside the same transaction as the effect, or use the gateway’s own idempotency key so the atomicity lives where the money does.

How do you test exactly-once in Playwright?

You inject the one failure real chaos testing usually misses: a success on the server followed by a lost response to the client. Killing the request before it lands proves nothing — the effect never ran. The bug only exists when the effect commits and the acknowledgment vanishes, so that’s the fault your test has to manufacture. Mock the model so the tool call is deterministic, point the agent at a real test backend that counts executions, and use route interception to let the first request through to the handler and then swallow its response.

tests/ai/exactly-once.spec.ts
import { test, expect } from '@playwright/test';
import { startCountingBackend } from './fixtures/counting-backend';
test('a lost tool response does not double-charge', async ({ page }) => {
const backend = await startCountingBackend(); // increments on real commit
let attempts = 0;
// Let the request reach the handler; drop the RESPONSE on attempt 1.
await page.route('**/tools/charge_card', async (route) => {
attempts += 1;
if (attempts === 1) {
await backend.commitCharge(route.request().postDataJSON()); // effect runs
await route.abort('connectionaborted'); // ...but the ack never arrives
} else {
await route.continue(); // retry hits the idempotent handler
}
});
await runAgent(page, 'Pay invoice #4471');
expect(attempts).toBeGreaterThan(1); // a retry did happen
expect(backend.chargeCount).toBe(1); // ...and it was absorbed
});

The two assertions matter equally. attempts > 1 proves the retry actually fired — otherwise you’re testing a code path that never executes and calling it green, the same trap as a fault test that quietly stops injecting faults. chargeCount === 1 proves the idempotency guard absorbed it. Assert only the second and a broken key generator still passes on the day the retry doesn’t happen to fire.

Pin the tool call

Mock the model to emit one charge_card call with fixed arguments and a fixed tool_call.id. This isolates the transport failure from model non-determinism — you’re testing delivery, not reasoning.

Commit, then drop the ack

Run the real effect against the counting backend, then abort the response. This is the post-commit response loss that in-request chaos testing skips entirely.

Assert the retry AND the single effect

Check that a retry occurred and that the side-effect count is exactly one. Both, every run, or the test can pass for the wrong reason.

Where should this test run, and what should it gate?

On the pull-request path, mocked and deterministic — this is cheap, it’s fast, and it has nothing to do with model quality, so it does not belong in your expensive nightly eval. Treat exactly-once as a contract every mutating tool must satisfy, the same way you’d assert an agent’s tool calls rather than its prose, and run it as a parameterized suite across every tool with a side effect: charges, refunds, shipment creation, email sends, ticket writes. Any tool that can’t survive a replayed call is a production incident with a countdown timer.

§ Delta · Tool retry behavior Duplicate side effects caught in CI

Before

Tests assume tool either succeeds or fails; retries untested

→ ↓

After

Post-commit response loss injected; exactly-once asserted per tool

One caveat worth its own test: idempotency keys expire. Stripe’s window is 24 hours; if your agent can legitimately resume a task a day later, a retry past the TTL will re-execute. And a retry storm under provider rate limiting can push attempts outside a shorter in-memory cache. Pick a TTL that outlives your longest legitimate retry window, and test the boundary — a key that expires between the effect and the retry is a double-charge with a delay.

The one thing to do today

Pick your single highest-blast-radius tool — the one that moves money, creates an order, or sends something a customer sees — and write the lost-response test for it before you write another eval case. You don’t need the whole framework: mock the model to call that one tool, commit the effect, abort the response, retry, and assert the side effect ran exactly once. If it fails, you just found a duplicate-charge bug before your customers did. If it passes, you’ve turned “we think it’s idempotent” into a check that runs on every pull request — which is the only version of that sentence a senior SDET is allowed to say out loud.

§ Frequently Asked FAQ
+ Why does an AI agent duplicate a side effect it already completed?

Because a lost response looks exactly like a failed request from the outside, and an agent retries on that ambiguity. The tool commits its effect on the server, but the acknowledgment is lost to a timeout or 5xx, so the caller retries something that already succeeded. Two layers make it worse: model SDKs (OpenAI, Anthropic) auto-retry twice by default, and the agent loop retries failed tool results. Without idempotency, each retry re-runs the effect.

+ Why doesn't generating an idempotency key fix the duplicate charge?

Because the key is usually minted inside the handler per call, so every retry gets a new key and the server sees a fresh request. The key has to be stable across retries of the same intent — derive it from the model’s tool_call.id (constant when the SDK replays a response) hashed with the arguments, and use it as a first-writer-wins guard that replays the stored result. See Stripe’s idempotency keys for the reference implementation.

+ How do you test exactly-once behavior for an AI agent in Playwright?

Inject a post-commit response loss. Mock the model so the tool call is deterministic, point the agent at a real test backend that counts executions, and use page.route to let the first request reach the handler, commit the effect, then route.abort() the response. The retry hits the idempotent handler. Assert both that a retry occurred (attempts > 1) and that the effect ran exactly once (chargeCount === 1).

+ Where should the exactly-once test run in CI?

On the pull-request path, mocked and deterministic. It tests delivery semantics, not model quality, so keep it out of the expensive nightly eval. Run it as a parameterized contract across every mutating tool — charges, refunds, shipment creation, emails, ticket writes — the same discipline as asserting tool calls over prose. Any tool that can’t survive a replayed call is a production incident waiting for a network blip.

§ Further Reading 03 of 03
01AI Testing

Your AI Agent Is Looping, and No Test Caught It

A max-iterations cap hides livelock instead of catching it. Here's the Playwright test a senior SDET writes to detect cyclic tool calls before they hit prod.

Read →
02AI Testing

Your AI Feature Has Three Clocks. They Disagree.

Your browser clock, your server clock, and the model's idea of now rarely agree. Pin all three in Playwright before a date bug quietly reaches production.

Read →
03AI Testing

Your AI Feature Works at 5 Docs and Breaks at 50

Your AI feature passes with three test documents and degrades at fifty. Build a Playwright context-length gate that finds the cliff before production does.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.