Idempotencia API: Ako navrhnúť endpointy odolné voči retry
Prve dvojite strhnutie som videl po bezpecnom deployi a odvtedy neverim, ze retry je len feature. “Zákazník zaplatil dvakrát!” Toto som počul o 7:00 ráno po tom, čo sme nasadili novú verziu platobného systému. Klient klikol na “Zaplatiť”, vypadla mu Wi-Fi, browser automaticky retried request, a my sme sprocesovali platbu dvakrát.
Problém? Náš POST endpoint nebol idempotentný. Riešenie? Idempotency-Key pattern.
Testované na: Node.js/TypeScript, Redis 7.x, PostgreSQL 15+. Produkčne overené na platobnom systéme s 10k+ transakcií denne.
Čo je Idempotencia
Idempotentná operácia - ak ju vykonáš viackrát, výsledok je rovnaký ako keby si ju vykonal raz.
GET /users/123 ✓ Idempotentné (čítanie)
PUT /users/123 ✓ Idempotentné (replace)
DELETE /users/123 ✓ Idempotentné (už zmazané = OK)
POST /orders ✗ NIE idempotentné (vytvorí nový)
PATCH /users/123 ⚠ Závisí od implementácie
Prečo POST nie je automaticky idempotentný
// Naivná implementácia
app.post('/orders', async (req, res) => {
const order = await db.orders.create(req.body);
await paymentService.charge(order.total);
res.json(order);
});
// Čo sa stane pri retry?
// 1. Request → DB insert → Payment → 200 OK
// 2. Retry → DB insert → Payment → 200 OK // DUPLICIT!
Idempotency-Key Pattern
Princíp
- Klient generuje unikátny
Idempotency-Keypre každý unikátny request - Server uloží key + response pri prvom spracovaní
- Pri retry server vráti uloženú response
Client Server
│ │
├── POST /orders ──────────────►│
│ Idempotency-Key: abc123 │
│ ├── Spracuj request
│ ├── Ulož (key, response)
│◄── 200 OK ────────────────────┤
│ │
│ (Network failure, retry) │
│ │
├── POST /orders ──────────────►│
│ Idempotency-Key: abc123 │
│ ├── Nájdi uloženú response
│◄── 200 OK (cached) ───────────┤
Implementácia v TypeScript
// middleware/idempotency.ts
import { Redis } from 'ioredis';
interface IdempotencyRecord {
status: 'processing' | 'completed' | 'failed';
response?: {
statusCode: number;
body: unknown;
headers: Record<string, string>;
};
createdAt: number;
expiresAt: number;
}
const IDEMPOTENCY_TTL = 24 * 60 * 60; // 24 hodín
export function idempotencyMiddleware(redis: Redis) {
return async (req: Request, res: Response, next: NextFunction) => {
// Len pre POST/PATCH požiadavky
if (!['POST', 'PATCH'].includes(req.method)) {
return next();
}
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!idempotencyKey) {
return res.status(400).json({
error: 'Missing Idempotency-Key header'
});
}
const cacheKey = `idempotency:${req.path}:${idempotencyKey}`;
// Skontroluj či už existuje
const existing = await redis.get(cacheKey);
if (existing) {
const record: IdempotencyRecord = JSON.parse(existing);
if (record.status === 'processing') {
// Request sa práve spracováva
return res.status(409).json({
error: 'Request is still being processed',
retryAfter: 1
});
}
if (record.status === 'completed' && record.response) {
// Vráť uloženú response
res.set('Idempotency-Replayed', 'true');
Object.entries(record.response.headers).forEach(([k, v]) => {
res.set(k, v);
});
return res.status(record.response.statusCode).json(record.response.body);
}
if (record.status === 'failed') {
// Predchádzajúci pokus zlyhal, povoľ retry
// (pokračuj ďalej)
}
}
// Označ ako "processing"
const processingRecord: IdempotencyRecord = {
status: 'processing',
createdAt: Date.now(),
expiresAt: Date.now() + IDEMPOTENCY_TTL * 1000
};
// Atomic set s NX (len ak neexistuje)
const set = await redis.set(
cacheKey,
JSON.stringify(processingRecord),
'NX',
'EX',
IDEMPOTENCY_TTL
);
if (!set) {
// Niekto iný práve spracováva
return res.status(409).json({
error: 'Concurrent request detected',
retryAfter: 1
});
}
// Intercept response
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
const completedRecord: IdempotencyRecord = {
status: res.statusCode >= 400 ? 'failed' : 'completed',
response: {
statusCode: res.statusCode,
body,
headers: Object.fromEntries(
Object.entries(res.getHeaders())
.filter(([k]) => ['content-type', 'x-request-id'].includes(k.toLowerCase()))
.map(([k, v]) => [k, String(v)])
)
},
createdAt: processingRecord.createdAt,
expiresAt: processingRecord.expiresAt
};
// Ulož výsledok (async, neblokuj response)
redis.set(
cacheKey,
JSON.stringify(completedRecord),
'EX',
IDEMPOTENCY_TTL
).catch(console.error);
return originalJson(body);
};
next();
};
}
Použitie
const app = express();
const redis = new Redis(process.env.REDIS_URL);
app.use(idempotencyMiddleware(redis));
app.post('/orders', async (req, res) => {
// Tento handler sa vykoná len raz pre daný Idempotency-Key
const order = await createOrder(req.body);
await chargePayment(order);
res.json(order);
});
Stavový Diagram Spracovania
┌─────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ key exists? ┌──────────────┐ │
│ │ START │───────────────────│ Check Status │ │
│ └──────────┘ no └──────────────┘ │
│ │ │ │
│ │ ┌──────────┴──────────┐ │
│ │ │ │ │
│ ▼ processing completed │
│ ┌──────────┐ │ │ │
│ │ Lock │ ▼ ▼ │
│ │ (NX SET) │ ┌──────────┐ ┌──────────┐ │
│ └──────────┘ │ 409 │ │ Return │ │
│ │ │ Conflict │ │ Cached │ │
│ │ └──────────┘ └──────────┘ │
│ ▼ │
│ ┌──────────┐ │
│ │ Process │ │
│ │ Request │ │
│ └──────────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ success error │
│ │ │ │
│ ▼ ▼ │
│ ┌────┐ ┌────────┐ │
│ │Save│ │ Save │ │
│ │ OK │ │ Failed │ │
│ └────┘ └────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Kde Ukladať Idempotency Keys
Redis (odporúčané)
// Výhody: rýchle, automatic expiry, atomic operations
const redis = new Redis({
host: process.env.REDIS_HOST,
keyPrefix: 'idempotency:',
retryStrategy: (times) => Math.min(times * 50, 2000)
});
PostgreSQL (alternatíva)
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
path VARCHAR(500) NOT NULL,
status VARCHAR(20) NOT NULL,
response JSONB,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);
-- Cleanup job
DELETE FROM idempotency_keys WHERE expires_at < NOW();
Porovnanie
| Kritérium | Redis | PostgreSQL |
|---|---|---|
| Latencia | ~1ms | ~5-10ms |
| Atomic lock | ✓ SET NX | ✓ INSERT…ON CONFLICT |
| Auto-expiry | ✓ TTL | ✗ Needs cron |
| Durability | ⚠ Depends on config | ✓ Full ACID |
| Operačná zložitosť | Stredná | Nízka |
Best Practices
1. Generovanie Idempotency-Key na klientovi
// Frontend
async function createOrder(orderData: OrderInput) {
const idempotencyKey = crypto.randomUUID();
return fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(orderData)
});
}
2. Key by mal byť viazaný na obsah
// Lepšie: hash z obsahu requestu
const idempotencyKey = crypto
.createHash('sha256')
.update(JSON.stringify({ userId, items, timestamp: Date.now() }))
.digest('hex');
3. Retry s exponential backoff
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// 409 = retry later
if (response.status === 409) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '1');
await delay(retryAfter * 1000);
continue;
}
return response;
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await delay(Math.pow(2, attempt) * 1000);
}
}
}
4. Loguj Idempotency-Key pre debugging
app.use((req, res, next) => {
const idempotencyKey = req.headers['idempotency-key'];
if (idempotencyKey) {
req.log = req.log.child({ idempotencyKey });
}
next();
});
Production Checklist
## Idempotency Implementation Checklist
### Middleware
- [ ] Validuj prítomnosť Idempotency-Key pre POST/PATCH
- [ ] Atomic lock (SET NX v Redis alebo INSERT...ON CONFLICT)
- [ ] Handle "processing" stav (409 Conflict)
- [ ] Vráť cached response s Idempotency-Replayed header
- [ ] TTL na keys (24-48h)
### Storage
- [ ] Redis/PostgreSQL s retries
- [ ] Cleanup expired keys (cron alebo TTL)
- [ ] Monitoring veľkosti storage
### Client
- [ ] Generuj unikátny key pre každý logický request
- [ ] Retry s exponential backoff
- [ ] Handle 409 Conflict gracefully
### Monitoring
- [ ] Metric: idempotency cache hit rate
- [ ] Metric: processing conflicts (409s)
- [ ] Alert: vysoký konflikt rate (možný bug)
- [ ] Log: idempotencyKey pre každý request
Špeciálne Prípady
Partial Failure
app.post('/orders', async (req, res) => {
const order = await db.orders.create(req.body); // ✓ Úspech
try {
await paymentService.charge(order.total); // ✗ Zlyhanie
} catch (error) {
// Čo teraz? Order existuje, platba nie.
// Retry vytvorí ďalší order!
// Riešenie: Transakcia alebo Saga pattern
await db.orders.delete(order.id);
throw error;
}
res.json(order);
});
Async Processing
app.post('/reports', async (req, res) => {
// Report sa generuje asynchrónne (5+ minút)
const job = await queue.add('generate-report', req.body);
// Vráť job ID, nie výsledok
res.status(202).json({
jobId: job.id,
status: 'processing',
checkUrl: `/reports/${job.id}`
});
// Client polluje status
});
Záver
Idempotencia je základný stavebný blok pre reliable API. Bez nej sú retry nebezpečné a môžu spôsobiť duplikáty, nekonzistencie a finančné straty.
Kľúčové princípy:
- Každý POST/PATCH potrebuje Idempotency-Key
- Atomic locking zabraňuje race conditions
- Cached responses zabezpečujú konzistentné odpovede
- Reasonable TTL (24-48h) pre balance medzi storage a použiteľnosťou
- Client retry logic s exponential backoff
FAQ
Aká má byť dĺžka Idempotency-Key?
UUID (36 znakov) je ideálne. Dlhšie hashe (SHA-256) sú tiež OK. Kratšie zvyšujú riziko kolízií.
Čo ak Redis nie je dostupný?
Fail open (dovoľ request bez idempotency) alebo fail closed (vráť 503). Závisí od use case - platby by mali fail closed.
Ako dlho držať keys?
24-48 hodín je štandard. Pre platby možno dlhšie. Moc dlho = príliš veľa storage.
Čo ak klient pošle iný body s rovnakým key?
Ignoruj nový body, vráť pôvodný response. Key je “promise” - rovnaký key = rovnaká operácia.
Súvisiace články
- Transactional Outbox - Reliable messaging pattern
- Soft Delete past - Idempotent delete operácie
Súvisiace články
Architectural Linting: Automatizovaná ochrana proti spaghetti kódu
Ako vynútiť architektonické pravidlá v CI/CD. Dependency Cruiser pre JS/TS, ArchUnit pre Java a praktické príklady konfigurácie.
Transactional Outbox: Ako vyriešiť Dual Write problém bez 2PC
Praktická implementácia Outbox patternu v Node.js/TypeScript s PostgreSQL LISTEN/NOTIFY. Race-condition case study a production-ready riešenie.
Redis AOF fsync latency špičky: keď sa durabilita stane tvojím p99
Redis AOF vie spraviť z durability p99 špičky: fsync tlak a BGREWRITEAOF fork CoW. Runbook na dôkaz, bezpečné mitigácie a guardrails.
Redis Memory Fragmentácia: Keď maxmemory Nestačí
Váš Redis má 4GB maxmemory ale RSS ukazuje 6GB. OOM killer zasiahne. Vysvetlím jemalloc fragmentáciu s reprodukciou a tuningom activedefrag.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.