Strona główna / Artykuły / Zrozumienie kluczy idempotentności w punktach końcowych POST w Node.js

Zrozumienie kluczy idempotentności w punktach końcowych POST w Node.js

Wyjaśnia, dlaczego żądania POST zawodzą w sposób nieprzewidywalny przy ponownych próbach, oraz jak klucze idempotencji generowane przez klienta pozwalają API Node.js bezpiecznie obsługiwać duplikatowe żądania.

1357 słów

Idempotencja to termin, który można znaleźć w różnych dokumentacjach API płatności, zazwyczaj wraz z definicją z słownika, którą wszyscy przeglądają pobieżnie, nie pojmując jej w pełni. Poniżej znajduje się próba wyjaśnienia tego pojęcia poprzez pytania, które programiści rzeczywiście zadają, gdy napotykają je w kodzie produkcyjnym, a nie poprzez abstrakcyjną wersję dostępną w podręcznikach.

Czym naprawdę jest „idempotentny”, gdy piszesz kod, a nie czytasz glosariusz?

Operacja jest idempotentna, gdy jej wykonanie raz daje ten sam stan końcowy co jej pięciokrotne wykonanie z rzędu przy dokładnie takich samych danych wejściowych. Weźmy PUT /users/8/name z ciałem { "name": "Jane" }: niezależnie od tego, czy wywołamy ją raz, czy pięć razy, imię użytkownika będzie „Jane”, a nic się nie akumuluje. Porównaj to z POST /orders, którego celem jest utworzenie nowego zamówienia — jeśli wywołamy go pięć razy, najprawdopodobniej otrzymamy pięć oddzielnych zamówień, a nie jedno, ponieważ nic w tej operacji nie zapobiega ich kumulowaniu się.

Dlaczego to jest tak ważne właśnie w przypadku żądań POST?

POST jest zazwyczaj metodą odpowiedzialną za tworzenie elementów, a sieci mają specyficzny sposób awarii, który sprawia, że jest to niebezpieczne: żądanie może zostać pomyślnie zrealizowane po stronie serwera, podczas gdy klient nigdy o tym nie dowie się, ponieważ sama odpowiedź ginie gdzieś w drodze powrotnej. Z perspektywy klienta widzi on jedynie przekroczenie czasu oczekiwania. Nie ma sposobu, by dowiedzieć się, czy zamówienie rzeczywiście zostało przetworzone, więc robi jedyne rozsądne co może: próbuje ponownie.

// the client's perspective, roughly
async function submitOrder(payload) {
  try {
    return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
  } catch {
    return submitOrder(payload); // did the first one actually fail, or just the response?
  }
}

Jeśli punkt końcowy /orders nie jest zaprojektowany tak, by tolerować tego typu próby ponowne, klient płaci dwa razy za jedną transakcję — a żadna ze stron nie jest wyraźnie winna. Z punktu widzenia klienta żądanie rzeczywiście zawiodło. Z perspektywy serwera natomiast faktycznie się udało.

Cóż więc jest potrzebne, aby punkt końcowy POST w Node był rzeczywiście idempotentny?

Konwencjonalnym rozwiązaniem jest to, aby klient generował jeden unikalny klucz na każdą operację logiczną, dołączał go jako nagłówek i pozwalał serwerowi użyć tego klucza do rozpoznania ponawianej żądania jako tej samej, którą już przetworzył, zamiast traktować je jako coś nowego.

app.post("/orders", async (req, res) => {
  const idempotencyKey = req.headers["idempotency-key"];
  if (!idempotencyKey) {
    return res.status(400).json({ error: "Idempotency-Key header required" });
  }
  const existing = await db.query(
    "SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
    [idempotencyKey]
  );
  if (existing) {
    return res.status(existing.status_code).json(JSON.parse(existing.response_body));
  }

  const order = await createOrder(req.body);
  await db.query(
    "INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
    [idempotencyKey, JSON.stringify(order), 201]
  );
  res.status(201).json(order);
});

Zadaniem klienta jest ponowne użycie tego samego klucza za każdym razem, gdy ponawia to samo żądanie logiczne – zazwyczaj jest to UUID utworzone raz, tuż przed wysłaniem pierwszej próby. Zadanie serwera jest prostsze: rozpoznać klucz, który już widział, i zwrócić przechowywany wynik zamiast ponownie wykonywać pracę.

Kto powinien generować klucz idempotencji, klient czy serwer?

To musi być klient, co dziwi wielu ludzi, ponieważ ich instynkt podpowiada coś innego. Gdyby to serwer tworzył klucz, każda próba ponowna przynosiłaby nowy klucz, co uczyniłoby cały mechanizm bezużytecznym — serwer nie miałby żadnych podstaw, by odróżnić próbę ponowną od nowego żądania. Klucz musi istnieć jeszcze przed wysłaniem pierwszej próby, właśnie po to, by można było ponownie użyć tego samego wartości, jeśli konieczna będzie kolejna próba.

A co, jeśli dwa identyczne żądania trafią dosłownie w tym samym momencie, a nie jedno po drugim?

To jest ta część, którą prawie zawsze źle wykonuje się przy pierwszej próbie zastosowania tego wzorca. Prosty podejście „sprawdź, a następnie wstaw” pokazane wcześniej zawiera w sobie warunek konkurencji: dwa żądania z tą samą kluczem mogą wykonać swoje polecenie SELECT, oboje mogą zwrócić wynik pusty i oboje mogą przejść do tworzenia zamówienia – co całkowicie podważa cel używania klucza od samego początku.

// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
  const idempotencyKey = req.headers["idempotency-key"];  try {
    await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
  } catch (err) {
    if (err.code === "23505") { // unique constraint violation
      const existing = await db.query(
        "SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
        [idempotencyKey]
      );
      return res.status(existing.status_code).json(JSON.parse(existing.response_body));
    }
    throw err;
  }

  const order = await createOrder(req.body);
  await db.query(
    "UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
    [JSON.stringify(order), 201, idempotencyKey]
  );
  res.status(201).json(order);
});

Ustalenie unikalnego ograniczenia dla kolumny key przenosi decyzję z logiki aplikacji na samą bazę danych: gdy dochodzi do kolizji dwóch jednoczesnych żądań, to baza danych decyduje, które z nich ma pierwszeństwo, a drugie otrzymuje wyraźny błąd, który można złapać, zamiast po prostu przejść niezauważone. Wyrażenie if w obsłudze ścieżki po prostu nie może sama zamknąć tej luki – takie problemy z równoczesnością muszą być rozwiązywane na warstwie, która faktycznie serializuje dostęp, a tą warstwą jest baza danych, a nie warunkowa kontrola w kodzie.

Czy cokolwiek z tego ma znaczenie również dla żądań GET?

Nie w ten sam sposób, i to regularnie myli ludzi. GET ma już ze względu na swoją konstrukcję być idempotentny — nie powinien nic zmieniać, więc swobodne ponawianie prób jest z natury bezpieczne i nie wymaga żadnego specjalnego traktowania. Wzorzec idempotency-key istnieje właśnie dla operacji, które tworzą lub modyfikują stan, gdzie nieostrożne ponawienie próby mogłoby podwoić efekt. Jeśli endpoint GET nie jest już bezpieczny do wielokrotnego wywoływania, prawdziwym problemem jest to, że wykazuje efekty uboczne, których w ogóle nie powinien mieć zgodnie z semantyką GET.

Jak długo klucz idempotency powinien pozostać ważny?

Idealnie na tyle długo, by obejmować realistyczne scenariusze ponawiania prób, ale nie na tyle długo, by przechowywane klucze gromadziły się nieograniczenie. Wiele platform płatniczych wybiera okres od 24 godzin do kilku dni. Zadanie czyszczenia zaplanowane w harmonogramie może następnie usunąć wygasłe wpisy:

await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");

Jeśli okno czasowe będzie zbyt wąskie, ponowna próba przetworzenia zamówienia, opóźniona z uzasadnionego powodu — na przykład gdy telefon klienta straci sygnał na dziesięć minut w trakcie procesu płatności — może wyjść poza to okno i spowodować powstanie prawdziwego duplikatu. Jeśli natomiast okno pozostanie otwarte na zawsze, tabela będzie się stale powiększać bez żadnych rzeczywistych korzyści.

Czy ten wzorzec jest istotny tylko w systemach płatniczych?

Płatności są zazwyczaj pierwszym miejscem, gdzie ludzie poznają tę lekcję, głównie dlatego, że podwójne pobranie pieniędzy to taki błąd, który w ciągu godziny skutkuje gniewnym e-mailem od klienta. Jednak podstawowy problem – klient, który nie potrafi odróżnić „moja prośba została odrzucona” od „moja prośba została przyjęta, ale nie dostałem żadnej odpowiedzi” – pojawia się wszędzie tam, gdzie występują skutki uboczne: wysyłanie e-maila, uruchamianie webhooka, tworzenie nowego konta, rozpoczynanie zadania w tle. Każda operacja, przy której ponowna próba jest możliwa, a jej wykonywanie dwukrotnie byłoby gorsze niż w ogóle jej nie wykonywać, nadaje się do zastosowania tego samego podejścia.

Literatura pokrewna

  • Częste błędy w kontraktach API, które niszczą niezawodność frontendu — Dowiedz się o dziesięciu powtarzających się wadach projektowania API backendowych – od niestabilnych struktur odpowiedzi po kruche mechanizmy paginacji – które podważają zaufanie do frontendu, oraz o sposobach ich naprawy.
  • REST API dla początkujących: zasoby, metody, kody stanu i brak stanu — Przewodnik w prostym języku wyjaśniający, czym jest REST API, pięć zasad, które sprawiają, że funkcjonuje, gdzie jest wykorzystywany w rzeczywistych zespołach oraz jak stworzyć i przetestować swój pierwszy taki interfejs.