skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
130 lines (107 loc) • 4.87 kB
Markdown
- Idempotency
- Idempotency Keys for Safe Retries
Design rules this middleware enforces:
- **Bind the cache to the full request, not just the key.** Cache under a
hash of `method + route + authenticated principal + request-body`. Reusing
one key across two different POSTs (or with a changed body) must NOT replay
the first response — return `422` on a key/body mismatch instead.
- **Release the lock on every exit path** (`finish`, `close`, and errors),
not only inside a `res.json` patch — otherwise thrown errors, non-JSON or
streaming responses, and crashes strand the lock until its short TTL.
- **Cache outcomes intentionally.** Persist deterministic results — `2xx`
and client errors (`4xx`, e.g. validation) — so retries are stable. Do NOT
cache `5xx`/timeouts: those are transient and the client should be able to
retry into a fresh attempt.
- **Two TTLs.** A short *lock* TTL (seconds, in case the process dies mid-flight)
and a longer *result* TTL (hours/days) for the cached response.
```typescript
// middleware/idempotency.ts
import { createHash } from 'crypto';
const LOCK_TTL = 60; // seconds — bounds a crash that strands the lock
const RESULT_TTL = 24 * 3600; // seconds — replay window for a completed request
// Endpoints where a missing key is a hard error (money-moving / side-effectful).
const REQUIRE_KEY = [/^\/api\/v1\/payments/, /^\/api\/v1\/transfers/];
function fingerprint(req: Request, key: string): string {
const principal = (req as any).user?.id ?? (req as any).apiKey?.id ?? 'anon';
const body = createHash('sha256').update(JSON.stringify(req.body ?? {})).digest('hex');
// route (not originalUrl) so query strings don't fragment the key
const route = (req as any).route?.path ?? req.path;
return createHash('sha256')
.update([req.method, route, principal, key, body].join('\n'))
.digest('hex');
}
async function idempotency(req: Request, res: Response, next: NextFunction) {
if (req.method !== 'POST') return next();
const idempotencyKey = req.headers['idempotency-key'] as string | undefined;
if (!idempotencyKey) {
if (REQUIRE_KEY.some((re) => re.test(req.path))) {
throw new AppError(400, 'IDEMPOTENCY_KEY_REQUIRED',
'Idempotency-Key header is required for this endpoint');
}
return next(); // optional elsewhere
}
const fp = fingerprint(req, idempotencyKey);
const resultKey = `idem:res:${fp}`;
const lockKey = `idem:lock:${fp}`;
// Detects "same key, different request" → reject rather than replay.
const keyGuard = `idem:key:${idempotencyKey}`;
const cached = await redis.get(resultKey);
if (cached) {
const { statusCode, body } = JSON.parse(cached);
res.setHeader('Idempotent-Replayed', 'true');
return res.status(statusCode).json(body);
}
// Reject reuse of the same key with a different method/route/body.
const priorFp = await redis.set(keyGuard, fp, 'EX', RESULT_TTL, 'NX', 'GET') as string | null;
if (priorFp && priorFp !== fp) {
throw new AppError(422, 'IDEMPOTENCY_KEY_REUSED',
'This Idempotency-Key was already used with a different request');
}
const locked = await redis.set(lockKey, '1', 'EX', LOCK_TTL, 'NX');
if (!locked) {
throw new AppError(409, 'REQUEST_IN_PROGRESS',
'A request with this idempotency key is already being processed');
}
// Capture the final payload, then persist + unlock on ANY terminal event.
let captured: { statusCode: number; body: unknown } | undefined;
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
captured = { statusCode: res.statusCode, body };
return originalJson(body);
};
let settled = false;
const settle = async () => {
if (settled) return;
settled = true;
// Cache deterministic outcomes (2xx + client errors); never cache 5xx.
if (captured && captured.statusCode < 500) {
await redis.set(resultKey, JSON.stringify(captured), 'EX', RESULT_TTL);
} else {
await redis.del(keyGuard); // let the client retry a failed attempt cleanly
}
await redis.del(lockKey); // always release, even on error/stream/abort
};
res.on('finish', settle); // response fully sent
res.on('close', settle); // client aborted before finish
next();
}
app.use('/api/v1', idempotency);
```
> Note: the `SET ... GET` option requires Redis ≥ 7.0. On older servers,
> replace the `keyGuard` step with a `GET` then a `SET ... NX`.
Client usage:
```typescript
// Client retries safely
const response = await fetch('/api/v1/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(), // Generate once, retry with same key
},
body: JSON.stringify({ amount: 5000, currency: 'usd' }),
});
```
---