Mocking Idempotency: Replaying the Same Key Should Return the Same Result
Quick answer: A mock that returns a fresh resource for every request cannot test the one behavior that makes payment and order APIs safe to retry: idempotency. Replaying the same idempotency key must return the original result, not create a second resource, and the mock has to model that state explicitly. If your tests only assert the first successful call, a client that retries on a timeout can still double-charge against the real API and every test will stay green. Mock the key semantics, not just the endpoint.
Idempotency is a retry-safety mechanism. A client that loses a connection does not know whether the server processed the request, so it reissues the same request with the same key. The server's job is to recognize the key and return the stored outcome rather than performing the action again. This is inherently stateful — the second call depends on state the first call stored — which is exactly why a stateless, request-response mock gets it wrong. Getting the mock honest is what lets you prove retry behavior before production ever sees a duplicate.
What a naive mock gets wrong
The typical generated mock returns a canned 201 for every POST, regardless of headers. Two requests produce two responses with new identifiers. Against such a mock, the client's retry logic looks correct because it receives a 201 each time, but it has never actually verified that the second response is the same record. The test asserts status codes and misses the invariant that matters.
// naive: every POST looks like a brand-new creation
app.post("/v1/charges", (req, res) => {
res.status(201).json({
id: "ch_" + Math.random().toString(36).slice(2),
status: "succeeded",
amount: req.body.amount,
});
});
Run that twice with one key and you get two different IDs — precisely the duplicate-charge behavior idempotency exists to prevent, presented as passing tests.
A mock that models key state
The fix is a small in-memory store keyed by the idempotency header. On the first request, perform the action and persist the response against the key. On a replay, return the stored response without re-executing. A different key behaves as a new request. This models the contract in a handful of lines.
const store = new Map();
app.post("/v1/charges", (req, res) => {
const key = req.headers["idempotency-key"];
if (!key) return res.status(400).json({ error: "idempotency-key required" });
if (store.has(key)) {
const saved = store.get(key);
return res.status(saved.status).set("Idempotent-Replay", "true").json(saved.body);
}
const body = { id: "ch_" + Date.now(), status: "succeeded", amount: req.body.amount };
store.set(key, { status: 201, body });
res.status(201).json(body);
});
Now the test can assert the real invariant: two calls with one key produce one resource with the same identifier, while two calls with different keys produce two resources.
it("replays the same result for a repeated key", async () => {
const headers = { "Idempotency-Key": "key-001" };
const a = await post("/v1/charges", { amount: 1999 }, headers);
const b = await post("/v1/charges", { amount: 1999 }, headers);
expect(b.status).toBe(201);
expect(b.headers["idempotent-replay"]).toBe("true");
expect(b.body.id).toBe(a.body.id);
});
it("treats a different key as a new charge", async () => {
const a = await post("/v1/charges", { amount: 10 }, { "Idempotency-Key": "k1" });
const c = await post("/v1/charges", { amount: 10 }, { "Idempotency-Key": "k2" });
expect(c.body.id).not.toBe(a.body.id);
});
Edge cases the mock should also cover
| Scenario | Correct behavior | Why it matters |
|---|---|---|
| Same key, same payload | Return stored result, no new action | The core retry path |
| Same key, different payload | 422 or 409 conflict | Prevents key reuse for a different intent |
| Request still in flight, same key | 409 processing | Two parallel retries must not both execute |
| Key expired or evicted | Process as new request | Providers keep keys for a finite window |
| Missing key header | 400 | Non-idempotent route is an explicit choice |
The same-key-different-payload case is frequently skipped and causes real bugs. Reusing a key with a changed amount is ambiguous and should be rejected, not treated as a replay. Encoding that in the mock forces the client to surface the conflict rather than silently sending a mismatched retry.
Keeping the mock tied to the contract
When idempotency is declared in the OpenAPI document — the header parameter, the replay response header, and the conflict status — a mock generated from the spec inherits the same rules the real server implements, instead of a hand-written mock drifting from the contract. Teams that generate mocks from examples in the spec can drive these exact scenarios by varying the documented header, and a contract check will flag a response that violates the declared status codes. That keeps the mock honest as the API evolves.
Frequently asked questions
Why does the mock need to be stateful?
Because idempotency is defined by what happens across two requests, not within one. The second response depends on state persisted by the first. A stateless mock has no way to distinguish a retry from a new request, so it cannot model the behavior — the key store is the minimal state required.
Is an idempotency key the same as a deduplication key?
They overlap but are not identical. Deduplication usually means silently dropping an exact duplicate; idempotency means returning the stored result of the original operation so the caller can continue as if the first request completed. The distinction matters for retries, where the client needs the original response, not just an acknowledgement.
What if my real API uses a fingerprint instead of a client key?
Some servers compute a fingerprint from the payload and derive idempotency that way. Model the same derivation in the mock so it matches production behavior. If the real API requires an explicit header, the mock must require it too — do not invent a mechanism the server does not have.
Does this replace end-to-end testing against the real service?
No. The stateful mock proves client retry logic quickly and deterministically; a staged end-to-end test confirms the real server stores and replays keys as documented. Use the mock for fast iteration and the real environment for final verification of the contract.
This is part of the API Mocking Guide — start there for the full set of mock patterns that mirror real API behavior.