idempotency

Idempotent API Operations

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "idempotency" with this command: npx skills add dadbodgeoff/drift/dadbodgeoff-drift-idempotency

Idempotent API Operations

Safely handle retries without duplicate side effects.

When to Use This Skill

  • Payment processing (charges, refunds)

  • Order creation and fulfillment

  • Any operation with side effects

  • APIs that may be retried by clients

  • Webhook handlers

What is Idempotency?

An operation is idempotent if executing it multiple times produces the same result as executing it once.

Request 1: POST /orders {item: "book"} → Order #123 created Request 2: POST /orders {item: "book"} → Order #123 returned (not #124) (same idempotency key)

Architecture

┌─────────────────────────────────────────────────────┐ │ Client Request │ │ Idempotency-Key: abc-123 │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Check Idempotency Store │ │ │ │ Key exists? │ │ ├─ Yes, completed → Return cached response │ │ ├─ Yes, in-progress → Return 409 Conflict │ │ └─ No → Continue processing │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Lock & Process Request │ │ │ │ 1. Acquire lock (set key as "processing") │ │ 2. Execute operation │ │ 3. Store response │ │ 4. Return response │ └─────────────────────────────────────────────────────┘

TypeScript Implementation

Idempotency Store

// idempotency-store.ts import { Redis } from 'ioredis';

interface IdempotencyRecord { status: 'processing' | 'completed'; response?: { statusCode: number; body: unknown; headers?: Record<string, string>; }; createdAt: number; completedAt?: number; }

interface IdempotencyConfig { redis: Redis; keyPrefix?: string; lockTtlMs?: number; // How long to hold processing lock responseTtlMs?: number; // How long to cache completed responses }

class IdempotencyStore { private redis: Redis; private keyPrefix: string; private lockTtl: number; private responseTtl: number;

constructor(config: IdempotencyConfig) { this.redis = config.redis; this.keyPrefix = config.keyPrefix || 'idempotency:'; this.lockTtl = config.lockTtlMs || 60000; // 1 minute this.responseTtl = config.responseTtlMs || 86400000; // 24 hours }

async get(key: string): Promise<IdempotencyRecord | null> { const data = await this.redis.get(this.keyPrefix + key); return data ? JSON.parse(data) : null; }

async acquireLock(key: string): Promise<boolean> { const record: IdempotencyRecord = { status: 'processing', createdAt: Date.now(), };

// SET NX = only set if not exists
const result = await this.redis.set(
  this.keyPrefix + key,
  JSON.stringify(record),
  'PX',
  this.lockTtl,
  'NX'
);

return result === 'OK';

}

async complete( key: string, response: IdempotencyRecord['response'] ): Promise<void> { const record: IdempotencyRecord = { status: 'completed', response, createdAt: Date.now(), completedAt: Date.now(), };

await this.redis.set(
  this.keyPrefix + key,
  JSON.stringify(record),
  'PX',
  this.responseTtl
);

}

async release(key: string): Promise<void> { await this.redis.del(this.keyPrefix + key); } }

export { IdempotencyStore, IdempotencyRecord, IdempotencyConfig };

Express Middleware

// idempotency-middleware.ts import { Request, Response, NextFunction } from 'express'; import { IdempotencyStore } from './idempotency-store';

interface IdempotencyOptions { store: IdempotencyStore; headerName?: string; methods?: string[]; paths?: RegExp[]; }

function idempotencyMiddleware(options: IdempotencyOptions) { const { store, headerName = 'Idempotency-Key', methods = ['POST', 'PUT', 'PATCH'], paths = [/.*/], } = options;

return async (req: Request, res: Response, next: NextFunction) => { // Only apply to specified methods if (!methods.includes(req.method)) { return next(); }

// Only apply to specified paths
if (!paths.some(p => p.test(req.path))) {
  return next();
}

const idempotencyKey = req.headers[headerName.toLowerCase()] as string;

// No key provided - proceed without idempotency
if (!idempotencyKey) {
  return next();
}

// Create a unique key combining the idempotency key with request details
const fullKey = `${req.method}:${req.path}:${idempotencyKey}`;

// Check for existing record
const existing = await store.get(fullKey);

if (existing) {
  if (existing.status === 'processing') {
    // Request is still being processed
    return res.status(409).json({
      error: 'Conflict',
      message: 'A request with this idempotency key is already being processed',
    });
  }

  if (existing.status === 'completed' &#x26;&#x26; existing.response) {
    // Return cached response
    res.status(existing.response.statusCode);
    if (existing.response.headers) {
      for (const [key, value] of Object.entries(existing.response.headers)) {
        res.setHeader(key, value);
      }
    }
    res.setHeader('X-Idempotent-Replayed', 'true');
    return res.json(existing.response.body);
  }
}

// Try to acquire lock
const acquired = await store.acquireLock(fullKey);

if (!acquired) {
  // Another request just acquired the lock
  return res.status(409).json({
    error: 'Conflict',
    message: 'A request with this idempotency key is already being processed',
  });
}

// Capture the response
const originalJson = res.json.bind(res);
let responseBody: unknown;

res.json = (body: unknown) => {
  responseBody = body;
  return originalJson(body);
};

// Store response after it's sent
res.on('finish', async () => {
  if (res.statusCode >= 200 &#x26;&#x26; res.statusCode &#x3C; 500) {
    // Store successful responses and client errors (but not server errors)
    await store.complete(fullKey, {
      statusCode: res.statusCode,
      body: responseBody,
    });
  } else {
    // Release lock for server errors (allow retry)
    await store.release(fullKey);
  }
});

next();

}; }

export { idempotencyMiddleware, IdempotencyOptions };

Usage

// app.ts import express from 'express'; import { Redis } from 'ioredis'; import { IdempotencyStore } from './idempotency-store'; import { idempotencyMiddleware } from './idempotency-middleware';

const app = express(); const redis = new Redis();

const idempotencyStore = new IdempotencyStore({ redis });

// Apply to all POST/PUT/PATCH requests app.use(idempotencyMiddleware({ store: idempotencyStore, methods: ['POST', 'PUT', 'PATCH'], }));

// Or apply to specific routes app.post('/orders', idempotencyMiddleware({ store: idempotencyStore, paths: [/^/orders$/], }), async (req, res) => { const order = await createOrder(req.body); res.status(201).json(order); } );

Python Implementation

idempotency.py

import json import time from typing import Optional, Dict, Any from dataclasses import dataclass import redis from functools import wraps

@dataclass class IdempotencyRecord: status: str # 'processing' | 'completed' response: Optional[Dict[str, Any]] = None created_at: float = 0 completed_at: Optional[float] = None

class IdempotencyStore: def init( self, redis_client: redis.Redis, key_prefix: str = "idempotency:", lock_ttl_ms: int = 60000, response_ttl_ms: int = 86400000, ): self.redis = redis_client self.key_prefix = key_prefix self.lock_ttl = lock_ttl_ms self.response_ttl = response_ttl_ms

def get(self, key: str) -> Optional[IdempotencyRecord]:
    data = self.redis.get(self.key_prefix + key)
    if not data:
        return None
    parsed = json.loads(data)
    return IdempotencyRecord(**parsed)

def acquire_lock(self, key: str) -> bool:
    record = {
        "status": "processing",
        "created_at": time.time(),
    }
    result = self.redis.set(
        self.key_prefix + key,
        json.dumps(record),
        px=self.lock_ttl,
        nx=True,
    )
    return result is True

def complete(self, key: str, response: Dict[str, Any]) -> None:
    record = {
        "status": "completed",
        "response": response,
        "created_at": time.time(),
        "completed_at": time.time(),
    }
    self.redis.set(
        self.key_prefix + key,
        json.dumps(record),
        px=self.response_ttl,
    )

def release(self, key: str) -> None:
    self.redis.delete(self.key_prefix + key)

FastAPI Middleware

fastapi_idempotency.py

from fastapi import Request, HTTPException from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware

class IdempotencyMiddleware(BaseHTTPMiddleware): def init( self, app, store: IdempotencyStore, header_name: str = "Idempotency-Key", methods: list = None, ): super().init(app) self.store = store self.header_name = header_name self.methods = methods or ["POST", "PUT", "PATCH"]

async def dispatch(self, request: Request, call_next):
    if request.method not in self.methods:
        return await call_next(request)

    idempotency_key = request.headers.get(self.header_name)
    if not idempotency_key:
        return await call_next(request)

    full_key = f"{request.method}:{request.url.path}:{idempotency_key}"

    # Check existing
    existing = self.store.get(full_key)
    if existing:
        if existing.status == "processing":
            raise HTTPException(
                status_code=409,
                detail="Request with this idempotency key is being processed",
            )
        if existing.status == "completed" and existing.response:
            return JSONResponse(
                content=existing.response["body"],
                status_code=existing.response["status_code"],
                headers={"X-Idempotent-Replayed": "true"},
            )

    # Acquire lock
    if not self.store.acquire_lock(full_key):
        raise HTTPException(
            status_code=409,
            detail="Request with this idempotency key is being processed",
        )

    try:
        response = await call_next(request)
        
        # Cache successful responses
        if 200 &#x3C;= response.status_code &#x3C; 500:
            body = b""
            async for chunk in response.body_iterator:
                body += chunk
            
            self.store.complete(full_key, {
                "status_code": response.status_code,
                "body": json.loads(body),
            })
            
            return JSONResponse(
                content=json.loads(body),
                status_code=response.status_code,
            )
        else:
            self.store.release(full_key)
            return response
    except Exception:
        self.store.release(full_key)
        raise

Decorator Pattern

idempotent_decorator.py

from functools import wraps

def idempotent(store: IdempotencyStore, key_func=None): """ Decorator for idempotent functions.

@idempotent(store, key_func=lambda args: args[0].order_id)
async def process_order(order: Order):
    ...
"""
def decorator(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        # Generate key
        if key_func:
            key = key_func(args, kwargs)
        else:
            key = f"{func.__name__}:{hash(str(args) + str(kwargs))}"

        # Check existing
        existing = store.get(key)
        if existing and existing.status == "completed":
            return existing.response["result"]

        # Acquire lock
        if not store.acquire_lock(key):
            raise Exception("Operation already in progress")

        try:
            result = await func(*args, **kwargs)
            store.complete(key, {"result": result})
            return result
        except Exception:
            store.release(key)
            raise

    return wrapper
return decorator

Usage

@idempotent(store, key_func=lambda args, kwargs: f"order:{kwargs.get('order_id')}") async def process_payment(order_id: str, amount: float): return await stripe.charges.create(amount=amount)

Client-Side Implementation

// idempotent-client.ts class IdempotentClient { private generateKey(): string { return crypto.randomUUID(); }

async post<T>(url: string, data: unknown, options?: RequestInit): Promise<T> { const idempotencyKey = this.generateKey();

const response = await fetch(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
    ...options?.headers,
  },
  body: JSON.stringify(data),
  ...options,
});

if (response.status === 409) {
  // Request in progress, wait and retry
  await new Promise(resolve => setTimeout(resolve, 1000));
  return this.postWithKey(url, data, idempotencyKey, options);
}

return response.json();

}

private async postWithKey<T>( url: string, data: unknown, idempotencyKey: string, options?: RequestInit, retries = 3 ): Promise<T> { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, ...options?.headers, }, body: JSON.stringify(data), ...options, });

if (response.status === 409 &#x26;&#x26; retries > 0) {
  await new Promise(resolve => setTimeout(resolve, 1000));
  return this.postWithKey(url, data, idempotencyKey, options, retries - 1);
}

return response.json();

} }

Best Practices

  • Include request details in key: Method + path + idempotency key

  • Set appropriate TTLs: Lock TTL < Response TTL

  • Handle 409 gracefully: Client should wait and retry

  • Don't cache server errors: Allow retry on 5xx

  • Use UUIDs for keys: Clients should generate unique keys

Common Mistakes

  • Using sequential IDs (collisions across users)

  • Caching server errors (prevents retry)

  • Too short response TTL (client retries get new result)

  • Not including request path in key (different endpoints collide)

  • Forgetting to release lock on error

Security Considerations

  • Validate idempotency key format

  • Rate limit by idempotency key

  • Don't expose internal state in 409 responses

  • Consider per-user key namespacing

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

oauth-social-login

No summary provided by upstream source.

Repository SourceNeeds Review
General

sse-streaming

No summary provided by upstream source.

Repository SourceNeeds Review
General

multi-tenancy

No summary provided by upstream source.

Repository SourceNeeds Review
General

deduplication

No summary provided by upstream source.

Repository SourceNeeds Review