Späť na blog

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

  1. Klient generuje unikátny Idempotency-Key pre každý unikátny request
  2. Server uloží key + response pri prvom spracovaní
  3. 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ériumRedisPostgreSQL
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:

  1. Každý POST/PATCH potrebuje Idempotency-Key
  2. Atomic locking zabraňuje race conditions
  3. Cached responses zabezpečujú konzistentné odpovede
  4. Reasonable TTL (24-48h) pre balance medzi storage a použiteľnosťou
  5. 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

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Idempotencia API: Ako navrhnúť endpointy odolné voči retry". https://www.michal-drozd.com/sk/blog/api-idempotency/ (Publikované 12. mája 2025).