Az Idempotency-Key: hogyan ne hozz létre kétszer fizetést egy hibás retry miatt

A hálózati hiba nem jelenti azt, hogy a POST nem futott le. Az Idempotency-Key-vel a retry nem hoz létre második fizetést, rendelést vagy webhook-eseményt.

Egy POST /payments kérésre a kliens időtúllépést kap. Biztosan nem történt fizetés? Nem. Lehet, hogy a szerver már elvégezte a műveletet, csak a válasz veszett el út közben. Ha a kliens vakon újraküldi a kérést, abból két terhelés, két rendelés vagy két e-mail lehet. Az Idempotency-Key erre a bizonytalan állapotra ad szerződést a kliens és az API között.

Böngészőből érkező ismételt kérések, idempotenciakulcs-tároló és API-szerver sematikus ábrája

A kulcs nem varázsolja át a POST-ot

A HTTP az idempotens metódusokat úgy határozza meg, hogy egy azonos kérés többszöri végrehajtásának szándékolt hatása azonos az egyszeriével. A GET, PUT és DELETE ezért általában biztonságosabban újrapróbálható; a POST viszont tipikusan új erőforrást vagy tranzakciót indít. Az idempotenciakulcs nem a HTTP-metódus jelentését írja át. A szervernek kell megjegyeznie, hogy egy konkrét üzleti szándékot már feldolgozott.

Az IETF HTTPAPI munkacsoport Idempotency-Key Internet-Draftja szerint a kliens egyedi értéket küld az Idempotency-Key fejlécben, a szolgáltatás pedig ezzel ismeri fel ugyanannak a kérésnek a retry-át. Ez még tervezet, tehát egy API saját dokumentációja az elsődleges szerződés, de a működési minta és a hibakódok jó kapaszkodót adnak.

A tervezet a fejlécet Structured Field stringként definiálja, ezért a szabványos érték idézőjeles, például Idempotency-Key: "6b51…". A kulcsot a felhasználói művelet kezdetén generáld, és minden automatikus vagy kézi retry ugyanazt vigye tovább. Egy új „Fizetek” kattintás viszont új kulcsot kap. Node-ban a crypto.randomUUID() erre megfelelő véletlen UUID-t ad.

Három állapotot érdemes megkülönböztetni

  • Új kulcs: hajtsd végre a műveletet, majd tartósítsd a kulcsot, a kérés ujjlenyomatát és a választ.
  • Azonos kulcs, azonos kérés, kész művelet: add vissza az eredeti státuszt és választ. Így a kliens végre megbízható választ kap, de nem jön létre második mellékhatás.
  • Azonos kulcs, még futó művelet: a tervezet erre 409 Conflict-ot javasol. A kliens kis késleltetéssel ugyanazzal a kulccsal próbálkozhat újra.

Van egy fontos negyedik ág is: ugyanaz a kulcs más törzzsel érkezik. Ezt nem szabad az első válasszal kiszolgálni, mert valószínűleg klienshiba vagy kulcs-újrahasznosítás történt. A tervezet 422 Unprocessable Content-ot javasol. Emiatt a kulcs önmagában kevés: tárold mellé a törzs kanonikus formájának hashét, illetve az útvonalat, metódust és a hitelesített felhasználó vagy tenant azonosítóját is.

Kicsi, futtatható Node-példa

Az alábbi példa memóriabeli tárral mutatja a protokollt. A kulcsot idézőjeles formában küldi, hashsel köti a kérés törzséhez, és külön kezeli a futó, kész és hibás ismétlést. Éles rendszerben a Map helyére közös, tartós tároló kell.

import { createHash, randomUUID } from 'node:crypto';

const requests = new Map();
let paymentsCreated = 0;

const fingerprint = (body) =>
  createHash('sha256').update(body).digest('hex');

async function handlePayment(key, body) {
  const existing = requests.get(key);
  const hash = fingerprint(body);

  if (existing?.fingerprint !== undefined && existing.fingerprint !== hash) {
    return { status: 422, body: { error: 'A kulcs másik kérésé' } };
  }
  if (existing?.state === 'pending') {
    return { status: 409, body: { error: 'Az eredeti kérés még fut' } };
  }
  if (existing?.state === 'completed') return existing.response;

  requests.set(key, { state: 'pending', fingerprint: hash });
  await new Promise((resolve) => setTimeout(resolve, 40)); // üzleti művelet
  const response = { status: 201, body: { paymentId: `pay_${++paymentsCreated}` } };
  requests.set(key, { state: 'completed', fingerprint: hash, response });
  return response;
}

const key = `"${randomUUID()}"`;
const body = JSON.stringify({ amount: 2490, currency: 'HUF' });
await handlePayment(key, body);      // 201, pay_1
await handlePayment(key, body);      // 201, ismét pay_1
await handlePayment(key, '{"amount":3990}'); // 422

A lényeg nem a Map, hanem a sorrend. A „pending” rekordnak az üzleti mellékhatás előtt láthatónak kell lennie, a végső válasznak pedig utána megmaradnia. Több Node-processz, több konténer vagy újraindítás mellett ez csak közös adatbázissal vagy erre alkalmas tárral működik. A fizetés és az idempotenciabejegyzés közti félresikerült folyamatot ugyanabban a tranzakcióban kell kezelni, ahol ez lehetséges; külső fizetési szolgáltatónál pedig a szolgáltató saját idempotenciakulcsa is a művelet része.

Nem örökké, és nem mindenhol ugyanaz a kulcs

Az API-nak expliciten dokumentálnia kell a kulcs élettartamát. Ha túl rövid, egy lassú mobilhálózatos retry kicsúszhat belőle; ha korlátlan, feleslegesen növekszik a tábla. A megőrzési idő üzleti döntés: egy fizetésnél hosszabb, egy átmeneti űrlapműveletnél rövidebb lehet. A kulcsot mindig a hívó identitásával és az endpointtal együtt indexeld. Egy globális UNIQUE(key) ütközést és akár más ügyfél válaszának kiszivárgását okozhatja.

Külön frontend- és API-origón a fejléc CORS-következménnyel jár. Az Idempotency-Key nem CORS-safelisted kérésfejléc, ezért a böngésző preflightot küld; az API Access-Control-Allow-Headers válaszában engedélyezze. A CORS dokumentációja részletesen leírja, hogy a böngésző az OPTIONS kérésben közli a tényleges kérés fejléceit. Ha ezt elfelejted, a szerveroldali deduplikáció hibátlan lehet, a böngészős kliens mégsem jut el hozzá.

Az idempotenciakulcs nem helyettesíti az optimistic lockingot: az előbbi azt mondja meg, hogy ugyanazt a parancsot ne végezd el kétszer, az utóbbi azt, hogy ne írj felül közben megváltozott adatot. A checkout, foglalás, webhook-feldolgozás, e-mail-küldés és „create” API-k viszont pontosan azok a helyek, ahol egy megtartott kulcs sokkal olcsóbb, mint utólag kibogozni a duplikált üzleti eseményeket.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük