home / skills / yanko-belov / code-craft / idempotency

idempotency skill

/skills/idempotency

This skill helps you implement idempotency for critical mutations using idempotency keys to prevent duplicates and ensure safe retries.

npx playbooks add skill yanko-belov/code-craft --skill idempotency

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
5.7 KB
---
name: idempotency
description: Use when creating mutation endpoints. Use when trusting frontend to prevent duplicates. Use when payments or critical operations can be repeated.
---

# Idempotency

## Overview

**Critical operations must be safe to retry. Use idempotency keys.**

Networks fail. Clients retry. Users double-click. Without idempotency, retries cause duplicate charges, orders, or data corruption.

## When to Use

- Payment processing endpoints
- Order creation
- Any operation that shouldn't happen twice
- Asked to "trust the frontend" to prevent duplicates

## The Iron Rule

```
NEVER rely on frontend to prevent duplicate requests.
```

**No exceptions:**
- Not for "frontend disables the button"
- Not for "we show a loading state"
- Not for "it rarely happens"
- Not for "users won't double-click"

## Detection: Duplicate Risk Smell

If mutations have no duplicate protection, STOP:

```typescript
// ❌ VIOLATION: No idempotency protection
app.post('/payments', async (req, res) => {
  const { userId, amount, cardToken } = req.body;
  
  // If this request retries, user gets charged twice!
  const payment = await stripeCharge(amount, cardToken);
  await db.payments.create({ userId, amount, stripeId: payment.id });
  
  res.json({ success: true });
});
```

What can go wrong:
- Network timeout → client retries → double charge
- User double-clicks → two requests → double charge
- Mobile app retry logic → multiple requests

## The Correct Pattern: Idempotency Keys

```typescript
// ✅ CORRECT: Idempotency key protection

app.post('/payments', async (req, res) => {
  // Require idempotency key
  const idempotencyKey = req.headers['idempotency-key'];
  if (!idempotencyKey) {
    return res.status(400).json({ 
      error: 'Idempotency-Key header is required' 
    });
  }
  
  const { userId, amount, cardToken } = validated(req.body);
  
  // Check for existing request with this key
  const existing = await db.idempotencyKeys.findOne({
    where: { key: idempotencyKey, userId }
  });
  
  if (existing) {
    // Return cached response
    return res.status(existing.statusCode).json(existing.response);
  }
  
  try {
    // Process the payment
    const payment = await stripeCharge(amount, cardToken);
    await db.payments.create({ userId, amount, stripeId: payment.id });
    
    const response = { success: true, paymentId: payment.id };
    
    // Cache the response
    await db.idempotencyKeys.create({
      key: idempotencyKey,
      userId,
      statusCode: 200,
      response,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
    });
    
    res.json(response);
  } catch (error) {
    // Cache error responses too (optional, depends on error type)
    throw error;
  }
});

// Client usage:
// POST /payments
// Headers: { "Idempotency-Key": "user-123-order-456-attempt-1" }
```

## Idempotency Key Design

### Key Generation (Client Side)
```typescript
// Option 1: UUID per request
const key = crypto.randomUUID();

// Option 2: Deterministic (better for retries)
const key = `${userId}-${orderId}-${timestamp}`;

// Option 3: Hash of request content
const key = hash(JSON.stringify({ userId, items, amount }));
```

### Key Storage (Server Side)
```typescript
interface IdempotencyRecord {
  key: string;
  userId: string;
  statusCode: number;
  response: any;
  createdAt: Date;
  expiresAt: Date;  // Clean up old keys
}
```

## What Needs Idempotency

| Operation | Risk | Solution |
|-----------|------|----------|
| Payments | Double charge | Idempotency key |
| Order creation | Duplicate orders | Idempotency key |
| Inventory decrement | Over-decrement | Idempotency key |
| Email sending | Duplicate emails | Idempotency key |
| Account creation | Duplicate accounts | Unique constraint + idempotency |

## Pressure Resistance Protocol

### 1. "Frontend Prevents Duplicates"
**Pressure:** "We disable the button, show loading state"

**Response:** Networks retry automatically. JavaScript crashes. Users have fast fingers.

**Action:** Backend idempotency. Frontend UX is not protection.

### 2. "It Rarely Happens"
**Pressure:** "Duplicates are rare edge cases"

**Response:** Rare × many users = many angry users. One duplicate charge = support nightmare.

**Action:** Protect all critical mutations.

### 3. "Users Won't Double-Click"
**Pressure:** "Our users are careful"

**Response:** Users have slow connections. Buttons are small. Frustration leads to clicking.

**Action:** Never rely on user behavior.

### 4. "Database Has Unique Constraint"
**Pressure:** "Duplicate insert will fail"

**Response:** Unique constraint throws error. User sees error. UX is terrible.

**Action:** Idempotency returns same success response.

## Red Flags - STOP and Reconsider

- Payment endpoints without idempotency
- "Frontend handles duplicate prevention"
- Network retries causing side effects
- Users reporting double charges
- No Idempotency-Key header support

**All of these mean: Add idempotency protection.**

## Quick Reference

| Unsafe | Safe |
|--------|------|
| Trust frontend | Require idempotency key |
| Error on duplicate | Return cached response |
| Assume single request | Design for retries |
| POST = new resource always | POST + key = at-most-once |

## Common Rationalizations (All Invalid)

| Excuse | Reality |
|--------|---------|
| "Frontend prevents it" | Networks retry. Users double-click. |
| "Rarely happens" | Rare × scale = many incidents. |
| "Users are careful" | Users are human. |
| "Unique constraint" | Constraints throw errors, not success. |
| "Too complex" | Simpler than handling support tickets. |

## The Bottom Line

**Require idempotency keys for all critical mutations.**

Never trust frontend protection. Cache responses by idempotency key. Return the same response for duplicate requests. Clean up old keys periodically.

Overview

This skill implements idempotency protection for mutation endpoints to ensure critical operations are safe to retry. It enforces Idempotency-Key handling, caches responses, and returns the same result for repeated requests. Use it to prevent duplicate charges, orders, inventory changes, and other non-idempotent side effects.

How this skill works

The skill requires an Idempotency-Key header and looks up a server-side record for that key and user before performing the mutation. If a cached record exists it returns the stored status and response. If not, it executes the operation, stores the response with status and expiration, and returns the result, ensuring at-most-once behavior across retries.

When to use it

  • Payment processing endpoints (charges, refunds)
  • Order creation and checkout flows
  • Inventory updates, email sends, or other side-effectful mutations
  • Whenever asked to “trust the frontend” to prevent duplicates
  • Any mutation that must not happen more than once

Best practices

  • Require an Idempotency-Key header and validate its presence on critical endpoints
  • Scope keys to a user or logical owner to avoid cross-user collisions
  • Cache both success and relevant error responses when appropriate, and expire records after a reasonable TTL
  • Design client-side key generation: UUIDs for unique attempts or deterministic keys for retryable operations
  • Log idempotent hits and provide metrics to detect frequent retries or misuse

Example use cases

  • Charge creation endpoint that returns the same payment id for repeated requests with the same key
  • Order API that stores and returns the original order response on retry instead of creating duplicates
  • Inventory decrement endpoint that applies the change once and returns cached confirmation for retries
  • Account creation guarded with a unique key plus database constraints to avoid duplicate accounts
  • Email sending endpoint that caches send results to avoid duplicate deliveries

FAQ

What should clients use as an idempotency key?

Use a unique UUID per logical attempt or a deterministic key combining userId and orderId for predictable retries.

How long should idempotency records be kept?

Keep records long enough to cover likely retries (commonly 24 hours) and run periodic cleanup to remove expired entries.