技能创建器
skill-creator
创建、改进和评估自定义技能,把专业流程转化为可重复使用的 AI 能力。
backend-idempotency【数据与研究】Use this skill when the user says 'idempotency', 'idempotent', 'idempotency key', 'exactly-once', 'retry safety', 'deduplication', 'duplicate detection', 'idempotent endpoint', 'safe retry'. This skill implements idempotency keys and exactly-once semantics so retries never result in duplicate side effects. Applies to any backend stack. Do NOT use for…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Guarantee exactly-once execution for mutating operations using idempotency keys, enabling safe retries without duplicate side effects. Idempotency is the foundation of reliable distributed systems — without it, network retries cause duplicate orders, double charges, and inconsistent state.
Exact user phrases: "idempotency", "idempotent", "idempotency key", "exactly-once", "retry safety", "deduplication", "duplicate detection", "idempotent endpoint", "safe retry".
Idempotency key handling design. No file unless requested.
Endpoint: {method} {path}
Key Source: {client-provided|server-generated}
Storage: {store type}
TTL: {duration}
4 lines per endpoint. 15 lines for full solution.
Is the operation naturally idempotent? (PUT, DELETE, same result every time)
├── Yes → No key needed, just use the resource identifier
└── No → Is the operation a creation (POST, different result each time)?
├── Yes → Client-provided idempotency key required
│ ├── Does the client always retry on network errors?
│ │ ├── Yes → Always require key on mutating endpoints
│ │ └── No → Still require it — clients are unreliable
│ └── Is the key collision possible?
│ ├── Yes → Use UUID v4 or v7 for key generation
│ └── No → Use business key (invoice ID, order number)
└── No → Server-generated idempotency based on request hash
├── Same request body within TTL → deduplicate
└── Different body → new operation
Who controls the idempotency key?
├── Client-provided (Idempotency-Key header)
│ ├── PRO: Client controls retries, predictable semantics
│ ├── CON: Badly-behaved clients can exhaust keys
│ └── Use when: External API, third-party integrations
└── Server-generated (request hash or business key)
├── PRO: No client cooperation needed
├── CON: Same body always returns same result (may not be desired)
└── Use when: Internal services, idempotent by design
What are your latency and consistency requirements?
├── Redis → Fast (~1ms), TTL-based expiry, atomic operations
│ ├── PRO: Low latency, built-in TTL
│ ├── CON: Data loss on node fail (if not persisted)
│ └── Use when: High-throughput, short TTL (< 24h)
├── PostgreSQL → Durable, transactional, complex queries
│ ├── PRO: ACID compliant, survives crashes
│ ├── CON: Slower (~5-10ms), requires cleanup job
│ └── Use when: Financial operations, long TTL (> 24h)
└── DynamoDB → Serverless, auto-scaling, TTL built-in
├── PRO: No ops, pay-per-use
├── CON: Eventually consistent by default
└── Use when: AWS ecosystem, variable throughput
Clients include Idempotency-Key header (UUID v4 or v7) on POST/PUT/PATCH requests. The key is a client-generated unique identifier for the operation.
async function handleRequest(req, res) {
const key = req.headers['idempotency-key'];
if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });
const existing = await idempotencyStore.get(key);
if (existing) {
// Return cached response — including error responses
return res.status(existing.status).json(existing.body);
}
// Process the request
const result = await processPayment(req.body);
await idempotencyStore.set(key, { status: 200, body: result }, { ttl: 3600 });
return res.json(result);
}
When two requests with the same key arrive simultaneously, only one should succeed. Use database-level locking:
-- PostgreSQL: INSERT ... ON CONFLICT
INSERT INTO idempotency_keys (key, status, body, created_at)
VALUES ($1, 'pending', null, NOW())
ON CONFLICT (key) DO NOTHING
RETURNING *;
-- If no row returned, another request holds the key — return 409 Conflict
// Redis: SET NX with TTL
const acquired = await redis.set(`idempotency:${key}`, 'pending', {
NX: true,
EX: 3600, // TTL in seconds
});
if (!acquired) {
// Key exists — wait for completion or return 409
const existing = await redis.get(`idempotency:${key}:result`);
if (existing) return JSON.parse(existing);
return res.status(409).json({ error: 'Request already in progress' });
}
After processing, update the idempotency row with the result:
UPDATE idempotency_keys SET status = 'completed', body = $2, updated_at = NOW() WHERE key = $1;
// Store result in Redis
await redis.set(`idempotency:${key}:result`, JSON.stringify(result), { EX: 3600 });
Critical: error responses must also be cached. If the first attempt returns a 503 and the client retries, the second attempt should return the same 503 — not process the request again:
async function handleWithIdempotency(req, res) {
const key = req.headers['idempotency-key'];
const cached = await store.get(key);
if (cached) return res.status(cached.status).json(cached.body);
try {
const result = await processRequest(req);
await store.set(key, { status: 200, body: result }, TTL);
res.json(result);
} catch (error) {
// ALSO cache error responses for idempotency
const errorResponse = { status: 502, body: { error: 'Upstream failed' } };
await store.set(key, errorResponse, TTL);
res.status(502).json(errorResponse.body);
}
}
Use a TTL index (Redis) or a background job (PostgreSQL) to delete keys after the TTL window:
-- PostgreSQL: background cleanup
DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours';
function idempotencyMiddleware(store: IdempotencyStore, ttl: number = 86400) {
return async (req: Request, res: Response, next: NextFunction) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const key = req.headers['idempotency-key'] as string;
if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });
const existing = await store.get(key);
if (existing) {
if (existing.status === 'pending') return res.status(409).json({ error: 'In-flight' });
return res.status(existing.status).json(existing.body);
}
await store.set(key, { status: 'pending' }, ttl);
const originalJson = res.json.bind(res);
res.json = function (body: any) {
store.set(key, { status: res.statusCode, body }, ttl);
return originalJson(body);
};
next();
};
}
@Post()
@Idempotent({ ttl: 3600 })
async createOrder(@Body() dto: CreateOrderDto): Promise<Order> {
return this.orderService.create(dto);
}
// Decorator implementation
function Idempotent(options: { ttl?: number } = {}) {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
const req = args.find(a => a?.headers?.raw);
const idempotencyKey = req?.headers['idempotency-key'];
if (!idempotencyKey) throw new BadRequestException('Idempotency-Key required');
const store = this.idempotencyStore;
const existing = await store.get(idempotencyKey);
if (existing) return existing;
const result = await original.apply(this, args);
await store.set(idempotencyKey, result, options.ttl ?? 86400);
return result;
};
};
}
class PostgresIdempotencyStore {
constructor(private pool: Pool) {}
async get(key: string): Promise<IdempotencyRecord | null> {
const result = await this.pool.query(
'SELECT * FROM idempotency_keys WHERE key = $1 AND created_at > NOW() - INTERVAL \'24 hours\'',
[key]
);
return result.rows[0] || null;
}
async set(key: string, value: IdempotencyValue, ttl: number): Promise<void> {
await this.pool.query(
`INSERT INTO idempotency_keys (key, status, response_status, response_body, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (key) DO UPDATE
SET status = $2, response_status = $3, response_body = $4, updated_at = NOW()`,
[key, value.status, value.statusCode, JSON.stringify(value.body)]
);
}
}
| Scenario | Recommended TTL | Rationale |
|---|---|---|
| Payment processing | 24 hours | Enough for payment confirmation |
| Order creation | 7 days | Long enough for any retry window |
| Email sending | 1 hour | Short window, email is idempotent |
| File upload | 24 hours | Large uploads may retry over hours |
| Webhook delivery | 7 days | Longest possible retry window |
| Storage | Latency | Durability | TTL | Cost | Best For |
|---|---|---|---|---|---|
| In-memory (Map) | <1μs | None | Manual | Free | Single-node, dev |
| Redis | ~1ms | Configurable | Built-in | Low-Med | High-throughput |
| PostgreSQL | ~5ms | Full | Manual | Low | Financial, durable |
| DynamoDB | ~10ms | Full | Built-in | Per-use | Serverless, AWS |
| Memcached | ~1ms | None | Built-in | Low | Simple caching |
Concurrent requests with the same key must be handled:
// Wait pattern: poll for completion
async function handleConcurrent(key: string, maxWaitMs = 5000): Promise<Response> {
const pollInterval = 100;
let waited = 0;
while (waited < maxWaitMs) {
const record = await store.get(key);
if (record && record.status !== 'pending') {
return { status: record.responseStatus, body: record.responseBody };
}
if (!record) break; // No concurrent request, proceed
await sleep(pollInterval);
waited += pollInterval;
}
return null; // Timeout — proceed with processing
}
Idempotency keys must be unpredictable. Never use sequential integers or timestamps:
// GOOD: UUID v4 — random, unpredictable
const key = crypto.randomUUID();
// GOOD: UUID v7 — time-ordered, random
const key = uuidv7();
// BAD: Sequential — attacker can enumerate keys
const key = `${timestamp}-${counter++}`;
Validate idempotency keys on input — never process a malformed key:
function validateIdempotencyKey(key: string): boolean {
// UUID format check
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(key)) return false;
return true;
}
Without careful design, idempotency keys can enable replay attacks:
keys_created - keys_expired + keys_in_flight| Storage | Reads/s | Writes/s | P99 Latency |
|---|---|---|---|
| In-memory | 1M+ | 1M+ | <100μs |
| Redis | 100K | 100K | ~1ms |
| PostgreSQL | 10K | 5K | ~5ms |
For Redis-based storage, configure eviction policy:
maxmemory-policy: allkeys-lru # Evict oldest keys first
maxmemory: 512mb # Cap idempotency storage
max_retry_duration × 2.| Pattern | Mechanism | Scope | Use Case |
|---|---|---|---|
| Idempotency Key | Client-provided key, server dedup | Single operation | Mutating API endpoints |
| Optimistic Locking | Version field, CAS | Resource update | Concurrent edits |
| Pessimistic Locking | DB row lock | Resource access | Long-running operations |
| Unique Constraint | DB unique index | Row creation | Preventing duplicate entities |
| Event Idempotency | Event ID dedup | Event processing | Consumer-side deduplication |
| State Machine | Guard transitions | Stateful operations | Workflow engines |
No artifact produced unless requested. Next skill: distributed-locking — coordinate access to shared resources across services. Carry forward: idempotency key storage, TTL configuration, endpoint list.
` interface EventObserver { onEvent(event: T): Promise; }
class EventBus { private observers: Set<EventObserver> = new Set(); subscribe(observer: EventObserver): void { this.observers.add(observer); } unsubscribe(observer: EventObserver): void { this.observers.delete(observer); } async emit(event: T): Promise { const results = Array.from(this.observers).map(o => o.onEvent(event)); await Promise.allSettled(results); } } `
config: defaults: timeout: 30s retryCount: 3 overrides: production: timeout: 60s retryCount: 5 development: timeout: 300s retryCount: 1
| Metric | Threshold | Severity | Action |
|---|---|---|---|
| Error rate | > 1% over 5min | Critical | Page on-call |
| p99 latency | > 2s over 5min | Warning | Investigate |
| Throughput drop | > 50% over 1min | Critical | Check upstream |
| Queue depth | > 1000 over 1min | Warning | Scale consumers |
| Disk usage | > 85% | Warning | Clean or expand |
| Memory usage | > 90% heap | Critical | Restart or scale |
| Anti-Pattern | Symptom | Root Cause | Solution |
|---|---|---|---|
| Premature optimization | Complex code for no measured benefit | Guessing instead of profiling | Measure first, optimize based on data |
| Copy-paste reuse | Duplicate code across codebase | Lack of abstraction | Extract shared logic into libraries |
| Gold-plating | Features with no current requirement | Over-engineering | YAGNI — build what's needed now |
| Magical thinking | Assumptions without validation | Skipping error handling | Handle all failure modes explicitly |
Cache hierarchy: L1 (in-memory local) → L2 (distributed Redis/Memcached) → L3 (CDN/Edge). Cache invalidation: TTL-based (simple, stale), event-based (complex, fresh), write-through (consistent, higher write latency), write-behind (fast writes, eventual consistency).