An idempotency key identifies one logical action only within a service’s defined scope and retention window. Reusing the same text is not a universal exactly-once guarantee. The service must associate that identity with the right principal and operation, coordinate competing attempts, and retain enough authoritative information to recognize the action later.
Scope is part of the identity
A key looked up globally can accidentally merge unrelated tenants or expose another caller’s stored result. Authenticate and authorize before consulting or replaying the record. Build the lookup identity from verified scope, a stable operation identifier, and the client’s action key; preserve component boundaries instead of ambiguous string concatenation.
Which dimensions belong in scope is an API decision. EC2 explicitly distinguishes regional and zonal idempotency. The same client token can permit separate actions in different regions or availability zones under those documented rules. Do not assume that moving a retry to another region preserves a global deduplication promise.
Request meaning matters too. The same scoped key with different parameters must follow a declared conflict policy. The example fingerprints one positive integer quantity; it does not compare arbitrary HTTP bodies, perform authentication, or infer business equivalence from a hash.
Couple the effect to its receipt
A lookup followed by an independent write can race: two workers both see no record and both execute. A unique key on the receipt table is insufficient if both business effects have already occurred before one receipt insert fails. The effect and remembered result need an appropriate atomic boundary.
When both live in one transactional database, they can be committed together. This fixture uses SQLite BEGIN IMMEDIATE before lookup and writes its business row and receipt in that transaction. SQLite permits one simultaneous writer, so competing connections serialize; the composite primary key also expresses the lookup identity.
The transaction’s protection ends at that database. An external payment, email, or message publication does not roll back because a local transaction fails. Such effects need their own end-to-end delivery and deduplication contract. Calling the whole workflow exactly once does not supply one.
Retention is a correctness boundary
If a remembered key expires while an old request can still return, that request may execute again. Stripe documents that a key reused after its record is pruned creates a new request; its minimum twenty-four-hour retention statement is provider-specific, not a universal timer.
Keep recovery windows and retention aligned, and distinguish a temporary deduplication receipt from a lasting business uniqueness rule. Expiring a receipt does not undo the original order. If the business forbids that order from ever being recreated, it needs an authoritative invariant that outlives a short retry cache.
Restoring an older receipt database, clearing state, or switching to an independent store can also forget completed work. Preserve the relationship between business data and operation records when planning recovery, not merely their individual file availability.
When it breaks
A lost response after commit leaves the client uncertain but does not remove the recorded result. A failed transaction before commit should leave neither a business effect nor a successful receipt. Those are different cases and require different evidence.
Do not treat database contention, unavailable storage, or unknown outcomes as an empty lookup. Nor should an in-progress attempt automatically permit another effect. Define whether duplicates wait, receive an in-progress response, or use another documented recovery path; successful replay is only one possible state.
Check it quickly
Save the block as scope_check.py and run python3 scope_check.py with Python 3.10 or newer. It ran with Python 3.14.4 and SQLite 3.53.0. The standard-library fixture creates a database in a private temporary directory and removes it afterward. Each attempt opens its own connection; tenant and operation inputs represent already verified fixture identities.
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing
import json
from pathlib import Path
import sqlite3
from tempfile import TemporaryDirectory
from threading import Barrier
with TemporaryDirectory(prefix="idempotency-scope-") as directory:
database = Path(directory) / "orders.sqlite"
with closing(sqlite3.connect(database)) as db:
db.executescript("""
CREATE TABLE effects (
id INTEGER PRIMARY KEY, tenant TEXT NOT NULL,
operation TEXT NOT NULL, quantity INTEGER NOT NULL CHECK (quantity > 0)
);
CREATE TABLE receipts (
tenant TEXT NOT NULL, operation TEXT NOT NULL, request_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, result TEXT NOT NULL, expires_at INTEGER NOT NULL,
PRIMARY KEY (tenant, operation, request_key)
);
""")
def execute(tenant, operation, key, quantity, now, fail_before_receipt=False):
# Tenant/operation are already authenticated/routed fixture identifiers.
assert type(quantity) is int and quantity > 0
fingerprint = json.dumps({"quantity": quantity}, separators=(",", ":"))
scope = (tenant, operation, key)
with closing(sqlite3.connect(database, timeout=5)) as db:
with db:
db.execute("BEGIN IMMEDIATE")
previous = db.execute(
"SELECT fingerprint, result, expires_at FROM receipts "
"WHERE tenant=? AND operation=? AND request_key=?", scope,
).fetchone()
if previous is not None and previous[2] > now:
if previous[0] != fingerprint:
return "conflict", None
return "replay", json.loads(previous[1])
if previous is not None:
db.execute("DELETE FROM receipts WHERE tenant=? AND operation=? AND request_key=?", scope)
cursor = db.execute(
"INSERT INTO effects (tenant, operation, quantity) VALUES (?, ?, ?)",
(tenant, operation, quantity),
)
result = {"effect_id": cursor.lastrowid, "quantity": quantity}
if fail_before_receipt:
raise RuntimeError("injected failure after effect insertion, before receipt insertion")
db.execute("INSERT INTO receipts VALUES (?, ?, ?, ?, ?, ?)",
(*scope, fingerprint, json.dumps(result), now + 60))
return "created", result
def counts():
with closing(sqlite3.connect(database)) as db:
return db.execute("SELECT (SELECT count(*) FROM effects), (SELECT count(*) FROM receipts)").fetchone()
gate = Barrier(8)
def attempt(_):
gate.wait(timeout=5)
return execute("north", "web-order", "intent-a", 2, 0)
with ThreadPoolExecutor(max_workers=8) as pool:
outcomes = list(pool.map(attempt, range(8)))
assert [kind for kind, _ in outcomes].count("created") == 1
assert [kind for kind, _ in outcomes].count("replay") == 7
original = outcomes[0][1]
assert all(result == original for _, result in outcomes) and counts() == (1, 1)
print("eight independent connections produce one effect, one receipt, and seven replays")
assert execute("north", "web-order", "intent-a", 3, 1) == ("conflict", None)
assert counts() == (1, 1)
assert execute("south", "web-order", "intent-a", 2, 1)[0] == "created"
assert execute("north", "batch-order", "intent-a", 2, 1)[0] == "created"
assert execute("north", "web-order", "intent-b", 2, 1)[0] == "created"
assert counts() == (4, 4)
print("changed parameters conflict; a different tenant, operation, or intent key has separate scope")
try:
execute("north", "web-order", "rolled-back", 1, 2, fail_before_receipt=True)
except RuntimeError as error:
assert str(error).startswith("injected failure")
else:
raise AssertionError("expected the transaction to fail")
assert counts() == (4, 4)
assert execute("north", "web-order", "rolled-back", 1, 2)[0] == "created"
assert counts() == (5, 5)
print("a raised exception rolls back the effect and receipt together; retry then creates one effect")
_, committed = execute("north", "web-order", "committed-no-reply", 1, 3)
# Discard the reply, then query through a fresh connection; no network loss is simulated here.
assert execute("north", "web-order", "committed-no-reply", 1, 3) == ("replay", committed)
assert counts() == (6, 6)
print("a fresh connection replays a committed result without adding another effect")
assert execute("north", "web-order", "intent-a", 2, 59) == ("replay", original)
kind, after_expiry = execute("north", "web-order", "intent-a", 2, 60)
assert kind == "created" and after_expiry["effect_id"] != original["effect_id"]
assert counts() == (7, 6)
print("at the explicit 60-second retention boundary, the same key creates a new effect")
print("SQLite", sqlite3.sqlite_version, "verified counts: 7 effects, 6 retained receipts")
Eight competing connections produce one effect, one receipt, and seven replays with identical results. Changed quantity conflicts. Changing tenant, operation, or action key creates separate work under the declared scope, reaching four effects and four receipts.
An exception after inserting an effect but before its receipt rolls back both. Retrying then creates one effect. A committed result is available through a fresh connection without adding work; this discards an application reply but does not simulate network loss.
Injected times verify replay at second fifty-nine and a new effect at the exact sixty-second expiration boundary. Final counts are seven effects and six retained receipts: the expired record disappears, not its original business row.
The sixty-second window is an explicit test policy, not a recommendation. This experiment does not test process crashes, power loss, distributed databases, external side effects, authentication, or production contention. BEGIN IMMEDIATE can still fail with database-busy errors outside the small successful workload.
Further reading
Sources
- EC2 API idempotency, regional and zonal scope plus parameter mismatch behavior, checked 2026-09-08.
- Stripe: Idempotent requests, retention, pruning, parameter checks, and concurrent-request behavior, checked 2026-09-08.
- SQLite transactions, one simultaneous writer, BEGIN IMMEDIATE, and SQLITE_BUSY, checked 2026-09-08.
- SQLite atomic commit, the atomic boundary around database changes and its storage assumptions, checked 2026-09-08.
- Python sqlite3, connection context-manager commit/rollback and explicit connection closing, checked 2026-09-08.