From bullpen
Use when designing or reviewing any state-changing HTTP endpoint (POST, PUT, PATCH, DELETE) — covers idempotency keys, request deduplication, retry safety, exactly-once semantics over at-least-once delivery, and the Stripe/standard idempotency-key pattern. Use anytime an endpoint creates resources, charges money, sends notifications, or has any non-reversible side effect.
How this skill is triggered — by the user, by Claude, or both
Slash command
/bullpen:idempotent-apisThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The internet retries. Phones retry. Webhook senders retry. Service meshes retry. **If your endpoint isn't idempotent, you have a duplicate-charge / duplicate-email / duplicate-record bug waiting to surface.** This skill is the playbook to make it not surface.
The internet retries. Phones retry. Webhook senders retry. Service meshes retry. If your endpoint isn't idempotent, you have a duplicate-charge / duplicate-email / duplicate-record bug waiting to surface. This skill is the playbook to make it not surface.
Every state-changing endpoint MUST be safe to call twice with the same input and produce the same result without duplicating side effects.
This isn't a "nice-to-have." A retry happens. A network blip happens. A user double-clicks. If your endpoint isn't idempotent, the bug is when, not if.
Idempotency-Key header.request_dedup table (Postgres + index on key works fine until ~10k req/s).in_progress → return 409 Conflict with Retry-After header.completed → return the cached response.failed → return the cached error.CREATE TABLE request_dedup (
key VARCHAR(255) PRIMARY KEY,
status VARCHAR(32) NOT NULL, -- 'in_progress' | 'completed' | 'failed'
request_hash VARCHAR(64) NOT NULL, -- detect "same key, different body" abuse
response_body JSONB,
status_code INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_request_dedup_created ON request_dedup(created_at);
async function handle(req) {
const key = req.headers['idempotency-key'];
if (!key) return error(400, 'Idempotency-Key required');
const bodyHash = sha256(req.rawBody);
const existing = await db.dedup.findByKey(key);
if (existing) {
if (existing.request_hash !== bodyHash) {
return error(422, 'Idempotency-Key reused with different body');
}
if (existing.status === 'in_progress') {
return error(409, 'Request still in progress', { 'Retry-After': '5' });
}
return reply(existing.status_code, existing.response_body);
}
await db.dedup.create({ key, status: 'in_progress', request_hash: bodyHash });
try {
const result = await doTheWork(req.body);
await db.dedup.update(key, { status: 'completed', response_body: result, status_code: 200 });
return reply(200, result);
} catch (e) {
const errBody = serializeError(e);
await db.dedup.update(key, { status: 'failed', response_body: errBody, status_code: 500 });
throw e;
}
}
Some operations are naturally idempotent — no key needed:
But:
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
| Skipping idempotency on "internal" POSTs | Retries happen even on internal networks (mesh retries, K8s restarts) | Always idempotent for state-changing ops |
| Using the request body hash as the key | Two legitimate identical requests get deduped incorrectly | Client-generated unique key, server stores both key + body hash |
| No TTL on the dedup table | Unbounded growth, slow queries | TTL 24-48h, partition by date |
| 200 OK on duplicate without indication | Client can't tell if it actually succeeded | Same response — explicit cache hit is fine, but don't lie about a fresh op |
| Idempotency check happens AFTER the side effect | Defeats the whole purpose | Lock the key BEFORE doing work |
| Different key per retry | Defeats deduplication | Client persists the key for the operation, reuses on retry |
Returning 200 when status is in_progress | Caller assumes success and doesn't retry the GET to fetch the result | 409 with Retry-After |
| Backend | OK at | Notes |
|---|---|---|
| Postgres unique index + UPSERT | <10k req/s | Default. Add the dedup TTL via a daily job. |
| Redis with NX (SET NX EX 86400) | <100k req/s | Fast, but lose data on Redis restart unless AOF/RDB tuned. |
| DynamoDB conditional put | Unlimited (pay) | Native TTL. AWS-only. |
| Cassandra w/ TTL | Unlimited (run) | Own the cluster. |
Webhook senders (Stripe, GitHub, Shopify) always retry on non-2xx. So:
evt_xxx, GitHub sends X-GitHub-Delivery, etc.Idempotency-Key header (or has natural idempotency)npx claudepluginhub manavarya09/bullpen --plugin bullpenGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.