Use when a request can fire the same external side effect twice (duplicate charge, duplicate email, duplicate webhook, double-provision), especially under retries or concurrency, when two same-key requests both report a cache/lookup MISS, or when an idempotency / dedupe key is checked and then acted on in two separate steps.
Scanned 8/30/2026
Install to Claude Code
npx -y skills add AIScientists-Dev/Flowtrace --skill writing_skills --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Writing Skills?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/aiscientists-dev-writing-skills)More formats (shields.io, HTML) on the badges page.
---
name: idempotent-side-effects
description: Use when a request can fire the same external side effect twice (duplicate charge, duplicate email, duplicate webhook, double-provision), especially under retries or concurrency, when two same-key requests both report a cache/lookup MISS, or when an idempotency / dedupe key is checked and then acted on in two separate steps.
---
> ⚠️ *Illustrative — distilled in a fictional demo run; not about any real project.*
# Idempotent Side Effects
## Overview
A side effect guarded by "look up the key, and if it's missing, do the thing, then record the
key" is a **check-then-act (TOCTOU) race**. Two concurrent requests with the same key both pass
the lookup before either records, so the effect fires twice.
**Core principle:** Check and claim must be ONE atomic operation at the shared store. Looking up
a key is not reserving it.
## When to Use
- A retried or concurrent request can duplicate an external effect (payment, email, webhook,
resource creation).
- Logs show **two requests with the same key both MISS** the cache/store.
- The guard is "SELECT then (later) INSERT", or an in-memory `set`/dict of seen keys.
- Duplicates appear only **under load** and vanish when requests are serialized — a race tell.
**Not for:** effects that are naturally idempotent (PUT to a fixed key, set-a-flag), or
single-threaded batch jobs with no concurrency.
## Core Pattern
```sql
-- BAD: check-then-act. Two requests both see "missing", both proceed.
SELECT id FROM idem WHERE key = :k; -- both return nothing
-- ...do the side effect...
INSERT INTO idem (key, ...) VALUES (:k, ...); -- both insert
-- GOOD: atomic reserve-or-get. Exactly one winner per key.
INSERT INTO idem (key, status) VALUES (:k, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING id; -- non-null => you won the race => you do the effect
-- null => someone else owns it => read & return their result
```
Only the **winner** performs the side effect. Losers wait for the winner's record to reach a
terminal state and return the **original** result (so a retry returns the original charge, not a
new one or an error).
## Quick Reference
| Concern | Do |
|---|---|
| Mutual exclusion | Atomic insert on a **UNIQUE** key at the shared store — not an in-process lock |
| Cross-process safety | Shared store (DB/Redis) does the serialization; in-process locks don't span workers/pods |
| Structural backstop | `UNIQUE (key)` constraint so a second row is impossible even via a new code path |
| Loser behavior | Wait on a **condition** (record status) with a **bounded deadline**, then return the original result |
| Crashed winner | Make a stale `in_progress` older than a lease **reclaimable** so retries don't hang forever |
| Throughput | Per-key serialization only — never a global lock |
## Common Mistakes
- **In-process lock** (`asyncio.Lock`, `threading.Lock`): serializes one process; multiple
workers each hold their own, so the race survives across pods. Use the shared store.
- **Global lock** "to be safe": serializes *all* requests — throughput cliff — and is still
per-process.
- **Fixing at the symptom** (PSP-side dedupe, client debounce): the local race and other
unguarded effects remain.
- **No UNIQUE constraint**: the atomic INSERT relies on `ON CONFLICT (key)`; without the
constraint there's no conflict to detect.
- **Busy-wait without timeout** for the loser: a crashed winner hangs the retry forever.
## Regression Test (write it first)
```python
# Fire N concurrent same-key requests; the external effect must happen exactly once,
# and every response must carry the SAME result id (the original).
async def test_concurrent_same_key_fires_once():
ids = await asyncio.gather(*[do_request(key="ik_test") for _ in range(50)])
assert fake_psp.charge_count("ik_test") == 1
assert len(set(ids)) == 1 # all callers get the original result
```
It must **fail** before the fix (multiple effects) and **pass** after — the red-green proof.
## Real-World Impact
PAY-4815 (payments-service): ~0.3% of charges double-debited under traffic spikes. Root cause was
`lookup()`/`reserve()` split with no per-key critical section. After atomic reserve-or-get +
`UNIQUE(idempotency_key)`: duplicate rate under load 74% → 0%. Distilled here so the next
dedupe-shaped bug is recognized, not re-derived.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!