IlmHamroh
JavaScript Full-stack/25-qism. HTTP, Express va REST API24/51-dars22 daqiqa
Mundarija (32)

Idempotency-Key: POST so'rovini xavfsiz qayta yuborish va ikki marta to'lovdan himoya

Qisqacha: Tarmoq uzilsa, mijoz javob kelganini bilmaydi va POST'ni qayta yuboradi — server esa ikkinchi buyurtma (yoki ikkinchi to'lov) yaratadi. Idempotency-Key sarlavhasi buni hal qiladi: mijoz har "niyat" uchun bitta tasodifiy kalit yaratib, har urinishda shuni yuboradi. Server kalitni birinchi ko'rganda ishni bajaradi va javobni saqlaydi; takrorda ishni qayta bajarmay, saqlangan javobni qaytaradi. Kalit hali bajarilayotgan bo'lsa — 409, boshqa tana bilan kelsa — 422. Standart — IETF qoralamasi (draft-ietf-httpapi-idempotency-key-header).

Bu darsda

  • Nega "timeout bo'ldi — qayta yubor" POST uchun xavfli ekanini o'lchov bilan ko'rasiz.
  • Kalit, so'rov izi (fingerprint) va saqlangan javob qanday ishlashini tushuntira olasiz.
  • Parallel takror (409), boshqa tana (422), yaroqsiz kalit (400) holatlarini to'g'ri qaytarasiz.
  • TTL, 5xx va uzilishda kalit bilan nima qilishni bilasiz — kanondagi haqiqiy xato bilan.
  • vazifalar-api ga Idempotency-Key middleware qo'shib, holat 24 ni commit qilasiz.

Oldin bilishingiz kerak: HTTP metodlari semantikasi, Xato javoblari: Problem Details, To'lov UI: Click, Payme va Uzum, crypto: tasodif, hash va HMAC.

1. Nega bu kerak?

HTTP metodlari darsida idempotent so'zini ko'rgansiz: bir xil so'rovni bir marta yoki besh marta yuborish natijasi bir xil. GET, PUT, DELETE — idempotent. POST esa yo'q: har yuborish yangi resurs yaratadi.

Endi vaziyat. Jasur telefonda "To'lash — 100 000 so'm" tugmasini bosdi. So'rov serverga yetib bordi, server pulni yechdi. Lekin javob qaytayotganda metro tunneliga kirdi — internet uzildi. Ilova 10 soniya kutdi va "xato" ko'rsatdi. Jasur nima qiladi? Yana bosadi. Server uchun bu — yangi POST. Natija: ikki marta yechilgan pul.

Mijoz uchun eng yomoni — u bilmaydi. Timeout ikki holatda bo'ladi: so'rov serverga yetmagan yoki javob qaytmagan. Tashqaridan ikkalasi bir xil ko'rinadi.

O'xshatish: bankdagi navbat chiptasi. Kassaga chiptangizni berasiz, kassir pulni o'tkazadi. Agar u "chiptangiz bo'yicha o'tkazma allaqachon bor" desa — ikkinchi marta o'tkazmaydi, birinchi chekni qaytaradi. Chipta raqami — Idempotency-Key.

To'lov UI darsida mijoz tomonini ko'rgansiz: kalit yaratish va uni sarlavhada yuborish. Bu darsda server tomonini quramiz — kalitni kim, qayerda va qancha saqlaydi.

2. Muammoni o'lchaymiz

2.1 Kalitsiz va kalit bilan

Quyidagi blok kichik server ochadi. Uning POST ishlovchisi 300 ms ishlaydi (sekin to'lov tizimi kabi). Mijoz esa 100 ms kutadi va voz kechadi — xuddi Jasurning ilovasi kabi. Har holatda uchta urinish: birinchisi, darhol ikkinchisi va 400 ms dan keyin uchinchisi. Blok Node uchun — o'z terminalingizda ishga tushiring:

js
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";

const payments = [];
const keys = new Map(); // kalit -> { state, saved? }

const server = createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;
  const key = req.headers["idempotency-key"];
  const entry = key && keys.get(key);
  if (entry?.state === "running") {
    res.writeHead(409, { "retry-after": "1" });
    return res.end("jarayonda");
  }
  if (entry?.state === "done") {
    res.writeHead(201, { "idempotency-replayed": "true" });
    return res.end(entry.saved);
  }
  if (key) keys.set(key, { state: "running" });
  await sleep(300); // sekin to'lov tizimi
  payments.push(JSON.parse(body).matn);
  const saved = JSON.stringify({ id: payments.length });
  if (key) keys.set(key, { state: "done", saved });
  res.writeHead(201);
  res.end(saved);
});
await new Promise((ok) => server.listen(0, "127.0.0.1", ok));
const url = `http://127.0.0.1:${server.address().port}/tolovlar`;

async function attempt(n, headers) {
  try {
    const res = await fetch(url, {
      method: "POST", headers,
      body: JSON.stringify({ matn: "To'lov: 100 000 so'm" }),
      signal: AbortSignal.timeout(100),
    });
    const mark = res.headers.has("idempotency-replayed");
    const replay = mark ? " (replayed)" : "";
    const text = await res.text();
    console.log(`  ${n}-urinish: ${res.status}${replay} ${text}`);
  } catch (error) {
    console.log(`  ${n}-urinish: ${error.name}`);
  }
}

for (const key of [undefined, "tolov-0001"]) {
  payments.length = 0;
  console.log(key ? `Kalit bilan (${key}):` : "Kalitsiz:");
  const headers = key ? { "idempotency-key": key } : {};
  await attempt(1, headers);
  await attempt(2, headers);
  await sleep(400);
  await attempt(3, headers);
  await sleep(400);
  console.log(`  serverda to'lovlar: ${payments.length}`);
}
server.closeAllConnections();
server.close();

Konsolda:

text
Kalitsiz:
  1-urinish: TimeoutError
  2-urinish: TimeoutError
  3-urinish: TimeoutError
  serverda to'lovlar: 3
Kalit bilan (tolov-0001):
  1-urinish: TimeoutError
  2-urinish: 409 jarayonda
  3-urinish: 201 (replayed) {"id":1}
  serverda to'lovlar: 1

Kalitsiz: mijoz uch marta xato ko'rdi, server esa uch marta to'lov qildi. Bu eng yomon kombinatsiya — foydalanuvchi "to'lov o'tmadi" deb o'ylaydi, puli esa uch marta ketgan.

Kalit bilan: birinchi urinish baribir timeout — server ishlayapti. Ikkinchi urinish 409 oladi: "shu kalitli so'rov hali bajarilmoqda". Uchinchisi — birinchi javobning nusxasi, (replayed) belgisi bilan. To'lov bitta.

kanon ham xuddi shu tajribani vazifalar-api bilan qilgan (t24-idempotentlik, Node 24.21.0): kalitsiz — 3 ta "To'lov" vazifasi, kalit bilan — TimeoutError, 409 + Retry-After: 1, 201 (replayed), serverda 1 ta.

2.2 Ketma-ketlik diagrammada

Kalit bilan urinishlarni vaqt bo'yicha kuzating — eng muhim joyi "jarayonda" holati:

sequenceDiagram
  participant M as Mijoz
  participant S as Server
  participant O as Kalit ombori
  M->>S: POST, Key: tolov-0001
  S->>O: yo'q bo'lsa "jarayonda" yoz
  Note over M: 100 ms — timeout
  M->>S: takror, o'sha kalit
  S->>O: holat?
  O-->>S: jarayonda
  S-->>M: 409 + Retry-After: 1
  S->>O: tayyor + javob
  M->>S: takror, o'sha kalit
  O-->>S: tayyor
  S-->>M: 201 (saqlangan javob)

Tekshirib ko'ring: Nega PUT /vazifalar/5 uchun Idempotency-Key odatda kerak emas, POST /vazifalar uchun esa kerak?

Javob

PUT resursni to'liq almashtiradi: ikki marta "5-vazifa = X" deyish natijasi bitta — u o'zi idempotent (metodlar darsi). POST esa har safar yangi vazifa yaratadi — takror yangi natija beradi. Kalit POST'ni idempotent qiladi. PATCH ham ba'zan idempotent emas (masalan "hisoblagichni +1"), unga ham kalit qo'llanishi mumkin.

3. Kalit qanday ishlaydi

3.1 Mijoz tomoni: bitta niyat — bitta kalit

Idempotency-Key — mijoz yaratadigan tasodifiy satr. Eng oddiy yo'li — crypto.randomUUID(): 3f1c2b8e-…. Qoida bitta: kalit niyat bilan bog'liq, urinish bilan emas.

Vaziyat Kalit
Timeout — qayta urinish o'sha kalit
409 "jarayonda" — Retry-After dan keyin o'sha kalit
Foydalanuvchi savatchani o'zgartirdi yangi kalit
Ikkinchi, alohida to'lov yangi kalit

Har urinishda yangi UUID yaratsangiz — kalitning foydasi nolga teng: server har birini "yangi" deb ko'radi.

3.2 Server tomoni: to'rt holat

Server kalit bilan kelgan POST'ni ko'rganda omboridan so'raydi va to'rt javobdan birini oladi:

Ombordagi holat Server nima qiladi Status
kalit yo'q "jarayonda" deb yozadi, ishni bajaradi, javobni saqlaydi asl status (201)
jarayonda ishni boshlamaydi 409 + Retry-After
tayyor, iz mos saqlangan javobni qaytaradi saqlangan status
tayyor, iz boshqa rad etadi 422

"Jarayonda" holati nega kerak? Uni olib tashlasangiz, ikki parallel urinish ikkalasi ham "kalit yo'q" deb ko'radi va ikkalasi ham to'lov qiladi. Shuning uchun "yo'q bo'lsa yoz" amali atomik bo'lishi shart — tekshirish va yozish orasida boshqa so'rov aralasha olmasin. Bitta Node jarayonida Map bu shartni bajaradi (JS bitta thread'da, get va set orasida await yo'q). Bir nechta server bo'lsa — Redis SET kalit qiymat NX PX 86400000 (Redis darsi).

3.3 IETF qoralamasi nima deydi

Sarlavha IETF'ning HTTPAPI ishchi guruhi qoralamasida yozilgan: draft-ietf-httpapi-idempotency-key-header. 2026-10-24 holatiga u hali RFC emas — oxirgi versiya -08 (2026-10-22, datatracker.ietf.org). Lekin Stripe, PayPal, Adyen kabi to'lov API'lari bu g'oyani yillar davomida ishlatib keladi.

Qoralama tavsiya qiladigan javoblar (hammasi "SHOULD"):

Holat Status
kalit majburiy, lekin berilmagan 400
kalit boshqa tana bilan qayta ishlatilgan 422
shu kalitli so'rov hali bajarilmoqda 409

Uchala holatda ham tana — Problem Details. Kanon jadvali shu bilan to'liq mos. Kanonda kalit ixtiyoriy: kalitsiz POST oddiy ishlaydi (22-qism frontend'i kalit yubormaydi — buzilmasin).

4. So'rov izi: kalit + tana

4.1 Nega faqat kalit yetmaydi

Mijoz xato qildi: birinchi to'lovning kalitini ikkinchi, boshqa summali to'lovga ham ishlatdi. Server faqat kalitga qarasa — ikkinchi to'lov o'rniga birinchisining javobini qaytaradi. Mijoz "to'lov o'tdi" deb o'ylaydi, aslida o'tmagan. Bu jim xato — eng xavflisi.

Yechim: kalit bilan birga so'rov izini (fingerprint) saqlash. Iz — metod, yo'l va tana hash'i (hash darsi). Takror kelganda iz solishtiriladi: boshqacha bo'lsa — 422.

4.2 Kanonik JSON

Bir tuzoq bor: {"a":1,"b":2} va {"b":2,"a":1} — ma'nosi bir xil, matni boshqa. Mijoz kutubxonasi kalitlar tartibini o'zgartirsa, iz ham o'zgaradi va server haqli takrorni 422 bilan rad etadi. Shuning uchun hash'dan oldin JSON kanonik shaklga keltiriladi: kalitlar alifbo tartibida. Bu blok brauzerda ham ishlaydi (Web Crypto):

js
function canonical(x) {
  if (Array.isArray(x)) return `[${x.map(canonical).join(",")}]`;
  if (typeof x === "object" && x !== null) {
    const keys = Object.keys(x).sort();
    const parts = keys.map(
      (k) => `${JSON.stringify(k)}:${canonical(x[k])}`,
    );
    return `{${parts.join(",")}}`;
  }
  return x === undefined ? "null" : JSON.stringify(x);
}

async function fingerprint(method, path, body) {
  const text = `${method} ${path}\n${canonical(body)}`;
  const bytes = new TextEncoder().encode(text);
  const hash = await crypto.subtle.digest("SHA-256", bytes);
  const b64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
  return b64.replaceAll("+", "-").replaceAll("/", "_")
    .replace(/=+$/, "");
}

const a = { matn: "Non olish #uy", bajarildi: false };
const b = { bajarildi: false, matn: "Non olish #uy" };
const c = { matn: "Sut olish #uy", bajarildi: false };
console.log(canonical(b));
console.log(JSON.stringify(a) === JSON.stringify(b));
const fa = await fingerprint("POST", "/vazifalar", a);
console.log(fa === await fingerprint("POST", "/vazifalar", b));
console.log(fa === await fingerprint("POST", "/vazifalar", c));
console.log(fa.length, fa);

Konsolda:

text
{"bajarildi":false,"matn":"Non olish #uy"}
false
true
false
43 ghTmeqI8oMD7Dq2wzGoX9zAwB932TTlCtB7713d6_3o

Oddiy JSON.stringify a va b ni farqli deb biladi (false). Kanonik iz esa bir xil (true), boshqa matnli c — boshqacha (false). Iz — base64url (Buffer darsi): 32 bayt → 43 belgi, +, /, = siz.

Serverda xuddi shu ish createHash("sha256") bilan sinxron bajariladi. Brauzerda crypto.subtle.digest asinxron — shuning uchun await.

Tekshirib ko'ring: canonical oxiridagi x === undefined ? "null" qatori nega kerak? Qachon body undefined bo'ladi?

Javob

Tanasiz POST'da (express.json() hech narsa o'qimasa) req.body — undefined. JSON.stringify(undefined) satr emas, undefined qaytaradi va iz matniga "undefined" so'zi tushardi. Kanonda bu holatni lint topgan: TypeScript JSON.stringify turini string deydi, lekin ish vaqtida undefined keladi. Aniq tekshiruv bilan yechilgan.

5. Javobni saqlash, TTL va nozik holatlar

5.1 Qaysi javoblar saqlanadi

Javob Kalit bilan nima qilinadi Nega
2xx saqlanadi asosiy maqsad
4xx (masalan 422) saqlanadi takror ham aynan shu xatoni olsin — natija barqaror
5xx kalit o'chiriladi server xatosi vaqtinchalik — mijoz qayta urina olsin

Saqlanadigan narsa — status, tana va muhim sarlavhalar (Location). Takrorda server qo'shimcha Idempotency-Replayed: true qo'yadi. Bu sarlavha qoralamada yo'q — kanon tanlovi. U mijozga va logga "bu nusxa" deb aytadi. Stripe javoblarida shunga o'xshash Idempotent-Replayed sarlavhasi uchraydi: Stripe'ning rasmiy idempotentlik sahifasida u tilga olinmagan, lekin 2026-03 dagi xavfsizlik maslahatnomasi (CVE-2026-34210) unga tayanadi.

Kanon qoidasi hamma joyda bir xil emas. Stripe hujjatiga ko'ra (2026-10 holatiga), uning v1 API'si birinchi javobni, 500 xatoni ham, saqlaydi va takrorda aynan uni qaytaradi. Kanon esa 5xx da kalitni o'chiradi — sababi pastdagi savolda. Ikkalasi ham himoyalanadigan tanlov. Muhimi — tanlovni hujjatda ochiq yozish.

5.2 TTL — kalit qancha yashaydi

Kalitlar abadiy saqlanmaydi — xotira tugaydi. TTL (time to live — yashash muddati) o'tgach kalit o'chadi va o'sha kalit bilan kelgan so'rov yangi deb hisoblanadi. Kanonda: 24 soat va eng ko'pi 10 000 kalit (eskilari boshidan tozalanadi). 24 soat — qayta urinishlar uchun yetarli: mijoz bir kundan keyin timeout'ni takrorlamaydi.

TTL ni test qilish uchun vaqt tashqaridan beriladi: new XotiraIdempotentlikOmbori(soat). Testda soxta soat 24 soat oldinga suriladi va kalit qayta ishlaydi.

5.3 Haqiqiy xato: uzilishda kalitni o'chirish

Kanonning birinchi variantida mantiqiy tuyulgan qoida bor edi: "mijoz uzilsa (res.on("close")) va javob yuborilmagan bo'lsa — kalitni o'chir, u qayta urina olsin". Tajriba (t24) buni rad etdi:

  1. Mijoz 100 ms da voz kechdi → kalit o'chirildi.
  2. Lekin handler to'xtamadi — u baribir vazifani yaratdi.
  3. Qayta urinish kalitni "yangi" deb ko'rdi va ikkinchi vazifani yaratdi.

Kalit bilan ham 2 ta to'lov — himoya butunlay ishlamadi. Tuzatish: uzilishda kalit o'chirilmaydi. U faqat javob res.json siz tugasa (masalan sendStatus) bo'shatiladi. Regressiya testi qo'shildi.

Saboq: mijoz uzildi — bu server ishini to'xtatdi degani emas. Node'da so'rov bekor bo'lsa, handler ichidagi await lar davom etadi (agar siz AbortSignal bilan o'zingiz to'xtatmasangiz).

5.4 Kalit doirasi

Kanonda kalitlar umumiy: ikki foydalanuvchi bir xil kalit yuborsa, to'qnashadi. Haqiqiy API'da kalit foydalanuvchi bilan birga saqlanadi: user:42:tolov-0001. Auth hali yo'q (28-qism) — shu sabab kanonda bu qadam keyinga qoldirilgan.

Tekshirib ko'ring: Server birinchi so'rovda 503 qaytardi (DB vaqtincha ishlamadi). Kalitni saqlasak nima bo'ladi?

Javob

Har takror o'sha saqlangan 503 ni oladi — 24 soat davomida. DB bir daqiqada tiklansa ham, mijoz shu to'lovni hech qachon o'tkaza olmaydi. Shuning uchun 5xx da kalit o'chiriladi: server xatosi "qayta urinib ko'ring" degani.

6. Hujumchi nigohi

Hujum Xavf Himoya
Juda uzun kalit (1 MB) xotira, log to'ladi ^[\x21-\x7e]{1,255}$ — 1..255 ko'rinadigan ASCII, aks holda 400
Million xil kalit ombor xotirasi tugaydi eng ko'pi 10 000 kalit + TTL; haqiqiy API'da — foydalanuvchiga limit
Begona kalitni taxmin qilish birovning saqlangan javobini (shaxsiy ma'lumot) olish kalit foydalanuvchiga bog'lanadi (28-qism); iz mos kelmasa 422
Kalitni sarlavhaga \r\n bilan sarlavha injeksiyasi regex bo'shliq va boshqaruv belgilarini o'tkazmaydi

Uchinchi qator muhim: kalit tasodifiy UUID bo'lsa, uni taxmin qilish amalda mumkin emas (122 bit tasodif). tolov-1, tolov-2 kabi ketma-ket kalitlar esa — taxmin qilinadi.

7. API qadami: Idempotency-Key (holat 24)

7.1 src/umumiy/idempotentlik.ts

Yangi fayl — to'liq (kanondan; uzun qatorlar telefon uchun bo'lingan, ma'no aynan). Ombor interfeysi 27-qismda Redis bilan almashadi:

ts
// idempotentlik.ts — Idempotency-Key (IETF
// draft-ietf-httpapi-idempotency-key-header): tarmoq uzilib mijoz
// POST'ni qayta yuborsa, ikkinchi vazifa yaratilmaydi —
// birinchi javob qaytariladi
import { createHash } from "node:crypto";
import type { RequestHandler } from "express";
import { HttpXato } from "./http-xato.ts";

export type SaqlanganJavob = {
  holat: number; tana: unknown; location?: string;
};

export type BoshlashNatijasi =
  | { tur: "yangi" }
  | { tur: "jarayonda" }
  | { tur: "tayyor"; izi: string; javob: SaqlanganJavob }
  | { tur: "boshqa-tana" };

/** Ombor interfeysi: 27-qismda Redis (SET NX PX) bilan almashadi */
export interface IdempotentlikOmbori {
  /** Atomik: kalit yo'q bo'lsa — "jarayonda" deb yozadi, "yangi" */
  boshla(kalit: string, izi: string, muddatMs: number):
    Promise<BoshlashNatijasi>;
  yakunla(kalit: string, javob: SaqlanganJavob): Promise<void>;
  /** 5xx yoki uzilishda — mijoz qayta urina olsin */
  ochir(kalit: string): Promise<void>;
}

type Yozuv = { izi: string; tugash: number; javob?: SaqlanganJavob };

export class XotiraIdempotentlikOmbori implements IdempotentlikOmbori {
  readonly #yozuvlar = new Map<string, Yozuv>();
  readonly #soat: () => number;
  readonly #maks: number;

  constructor(soat: () => number = Date.now, maks = 10_000) {
    this.#soat = soat;
    this.#maks = maks;
  }

  #tozala(): void {
    const hozir = this.#soat();
    // Map qo'shilish tartibida — muddati bir xil bo'lgani uchun
    // eng eskilari boshida
    for (const [k, y] of this.#yozuvlar) {
      if (y.tugash > hozir && this.#yozuvlar.size <= this.#maks) break;
      this.#yozuvlar.delete(k);
    }
  }

  boshla(kalit: string, izi: string, muddatMs: number):
    Promise<BoshlashNatijasi> {
    this.#tozala();
    const y = this.#yozuvlar.get(kalit);
    if (y === undefined) {
      this.#yozuvlar.set(kalit, { izi, tugash: this.#soat() + muddatMs });
      return Promise.resolve({ tur: "yangi" });
    }
    if (y.izi !== izi) return Promise.resolve({ tur: "boshqa-tana" });
    if (y.javob === undefined) return Promise.resolve({ tur: "jarayonda" });
    return Promise.resolve({ tur: "tayyor", izi: y.izi, javob: y.javob });
  }

  yakunla(kalit: string, javob: SaqlanganJavob): Promise<void> {
    const y = this.#yozuvlar.get(kalit);
    if (y !== undefined) y.javob = javob;
    return Promise.resolve();
  }

  ochir(kalit: string): Promise<void> {
    this.#yozuvlar.delete(kalit);
    return Promise.resolve();
  }
}

/** Kalitlar tartibidan qat'i nazar bir xil matn:
 *  {"b":1,"a":2} va {"a":2,"b":1} — bitta iz */
function kanonik(x: unknown): string {
  if (Array.isArray(x)) return `[${x.map(kanonik).join(",")}]`;
  if (typeof x === "object" && x !== null) {
    return `{${Object.keys(x)
      .sort()
      .map((k) => `${JSON.stringify(k)}:${
        kanonik((x as Record<string, unknown>)[k])}`)
      .join(",")}}`;
  }
  // JSON.stringify(undefined) ish vaqtida undefined qaytaradi
  // (TS turi esa string deydi) — tanasiz so'rov
  return x === undefined ? "null" : JSON.stringify(x);
}

const KALIT = /^[\x21-\x7e]{1,255}$/; // ko'rinadigan ASCII, bo'shliqsiz
export const MUDDAT_MS = 24 * 60 * 60 * 1000;

export function idempotentlik(
  ombor: IdempotentlikOmbori, muddatMs = MUDDAT_MS,
): RequestHandler {
  return async (req, res, next) => {
    const kalit = req.get("idempotency-key");
    if (req.method !== "POST" || kalit === undefined) {
      next();
      return;
    }
    if (!KALIT.test(kalit)) {
      throw new HttpXato(400,
        "Idempotency-Key — 1..255 ko'rinadigan ASCII belgi",
        { kod: "idempotentlik-kalit",
          sarlavha: "Idempotency-Key yaroqsiz" });
    }
    const matn = `${req.method} ${req.originalUrl}\n${kanonik(req.body)}`;
    const izi = createHash("sha256").update(matn).digest("base64url");
    const holat = await ombor.boshla(kalit, izi, muddatMs);
    if (holat.tur === "boshqa-tana") {
      throw new HttpXato(422,
        "Bu Idempotency-Key boshqa so'rov tanasi bilan ishlatilgan",
        { kod: "idempotentlik-boshqa-tana",
          sarlavha: "Idempotency-Key qayta ishlatildi" });
    }
    if (holat.tur === "jarayonda") {
      throw new HttpXato(409, "Shu kalitli so'rov hali bajarilmoqda", {
        kod: "idempotentlik-jarayonda",
        sarlavha: "So'rov bajarilmoqda",
        httpSarlavhalar: { "Retry-After": "1" },
      });
    }
    if (holat.tur === "tayyor") {
      const { javob } = holat;
      res.set("Idempotency-Replayed", "true");
      if (javob.location !== undefined) res.location(javob.location);
      res.status(javob.holat).json(javob.tana);
      return;
    }
    // Yangi: javobni ushlab saqlaymiz (xato javobi ham res.json
    // orqali o'tadi — Problem Details)
    const asl = res.json.bind(res);
    let ushlandi = false;
    res.json = (tana: unknown) => {
      ushlandi = true;
      const location = res.get("Location");
      const saqla =
        res.statusCode >= 500
          ? ombor.ochir(kalit)
          : ombor.yakunla(kalit, location === undefined
            ? { holat: res.statusCode, tana }
            : { holat: res.statusCode, tana, location });
      saqla.catch((x: unknown) => {
        console.error(
          `[${req.sorovId}] idempotentlik omboriga yozilmadi`, x);
      });
      return asl(tana);
    };
    // Javob umuman yuborilmay ulanish uzilsa — kalit "jarayonda"
    // qolib ketmasin.
    // DIQQAT: mijoz uzilsa (timeout) kalitni O'CHIRMAYMIZ — handler
    // davom etib vazifani baribir yaratadi; birinchi variant "close"
    // da o'chirardi va qayta urinish IKKINCHI vazifani yaratdi (t24).
    // Javob res.json'siz tugasa (sendStatus, end) — saqlab bo'lmaydi,
    // kalit bo'shatiladi
    res.on("finish", () => {
      if (!ushlandi) void ombor.ochir(kalit);
    });
    next();
  };
}

Ikki joyga qarang. Birinchisi — res.json ni o'rash: xato javoblari ham (Problem Details) res.json orqali o'tadi, shuning uchun 422 ham saqlanadi. Ikkinchisi — res.on("finish"): javob res.json siz tugasa, kalit bo'shatiladi. "close" emas — «Haqiqiy xato» bo'limidagi muammo aynan shu yerda edi.

7.2 Ulash va CORS

src/ilova.ts da middleware faqat /vazifalar ga ulanadi. CORS ro'yxatlari kengaydi — brauzer bu sarlavhalarni yuborishi va o'qishi uchun (CORS chuqur — keyinroq):

ts
app.use(cors({
  origin: [...corsOriginlar],
  allowedHeaders: ["Content-Type", "Idempotency-Key", "X-Request-Id"],
  exposedHeaders: [
    "Location", "X-Request-Id", "Idempotency-Replayed",
  ],
  maxAge: 600,
}));
// …
app.use("/vazifalar", idempotentlik(idempotentlikOmbori),
  vazifalarRouteri(xizmat));

Tartib muhim. Kalitli takroriy POST /vazifalar zanjirdan shunday o'tadi:

Qadam Middleware Natija
1 cors(...) Idempotency-Key ruxsat etilgan sarlavhalar ro'yxatida — preflight o'tadi
2 express.json(...) req.body tayyor — iz hisoblash uchun kerak
3 idempotentlik(...) kalit "tayyor", iz mos — saqlangan 201 qaytariladi, next() chaqirilmaydi
4 vazifalarRouteri ishlamaydi — ikkinchi vazifa yaratilmaydi

idempotentlik ni express.json dan oldin qo'ysangiz, req.body hali undefined bo'ladi. Unda har tananing izi bir xil chiqadi va "boshqa tana" holati hech qachon ushlanmaydi.

7.3 Natija (kanon curl, sarlavhalar qisqartirilgan)

bash
curl -i -X POST $A/vazifalar -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: kanon-24-0001' -d '{"matn":"Non olish #uy"}'
# xuddi shu buyruq ikkinchi marta
curl -X POST $A/vazifalar -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: kanon-24-0001' -d '{"matn":"Boshqa"}'
text
HTTP/1.1 201 Created
Location: /vazifalar/1
{"id":1,"matn":"Non olish #uy","bajarildi":false,"teglar":["uy"],…}

HTTP/1.1 201 Created
Idempotency-Replayed: true
Location: /vazifalar/1
{"id":1,"matn":"Non olish #uy","bajarildi":false,"teglar":["uy"],…}

{"type":"https://vazifalar-api.example/xatolar/idempotentlik-boshqa-tana","title":"Idempotency-Key qayta ishlatildi","status":422,"detail":"Bu Idempotency-Key boshqa so'rov tanasi bilan ishlatilgan","instance":"/vazifalar","kod":"idempotentlik-boshqa-tana"}

Ikkinchi javob — birinchisining aynan nusxasi, id ham 1. Uchinchi so'rov boshqa tana bilan — 422.

7.4 Testlar va commit

test/idempotentlik.test.ts sekin soxta ombor (150 ms) bilan 8 holatni tekshiradi. Takror → bitta vazifa + replayed + bir xil Location; kalitsiz → 2 ta; boshqa tana → 422; yaroqsiz kalit → 400; parallel → 201 + 409 (Retry-After); 422 ham saqlanadi; TTL o'tgach qayta ishlaydi (soxta soat); uzilish regressiyasi. Oldingi darsdagi test/xato.test.ts bilan birga testlar 17 → 25.

Kanon tekshiruvi (tekshiruv-24): tip 0, lint 0, test 25/25. Endi commit (o'tgan darsdagi Problem Details ham shu commit'da):

bash
npm run check
git add -A
git commit -m "Xatolar Problem Details (RFC 9457) formatida; \
POST /vazifalar uchun Idempotency-Key"

8. Ko'p uchraydigan xatolar

1. Har urinishda yangi kalit. fetch(url, { headers: { "Idempotency-Key": crypto.randomUUID() } }) ni qayta urinish sikli ICHIDA yozish. Kalit sikldan oldin, bir marta yaratiladi.

2. "Tekshir, keyin yoz" atomik emas. if (!(await redis.get(k))) await redis.set(k, …) — ikki await orasida parallel so'rov o'tib ketadi. Bitta atomik amal kerak: SET … NX.

3. Faqat kalitni solishtirish. Iz saqlanmasa, boshqa tana bilan kelgan so'rov jim ravishda eski javobni oladi.

4. 5xx ni saqlash. Vaqtinchalik xato 24 soatga "muzlaydi".

5. Mijoz uzilganda kalitni o'chirish. Kanon xatosi: handler davom etadi va takror ikkinchi resurs yaratadi.

6. Takrorda handler'ni qayta chaqirish. "Javobni qayta hisoblaymiz" — yo'q: saqlangan javob qaytariladi, biznes mantiq ikkinchi marta ishlamaydi.

9. Mashqlar

Yechimlar kurs/mashqlar/25/24-idempotency-key/ da.

1-mashq (oson): to'ldiring

  • Shu kalitli so'rov hali bajarilayotganda status —
  • Kalit boshqa tana bilan qayta ishlatilsa —
  • 5xx javobda kalit bilan nima qilinadi?
  • Kanondagi kalit yashash muddati (soat) —
  • Qayta urinishda kalit yangi yaratiladimi?
Yechim
  • 409 Conflict, Retry-After bilan.
  • 422 — "Unprocessable Content".
  • o'chiriladi — server xatosi vaqtinchalik, mijoz qayta urinsin.
  • 24 — kanon MUDDAT_MS.
  • yo'q — bitta niyat, bitta kalit.

2-mashq (o'rta): xotiradagi ombor

createKeyStore(clock) yozing: begin(key, print) — { kind: "new" | "running" | "done" | "other-body", response? }, finish(key, response), remove(key). TTL — 1000 ms (clock() bilan). Soxta soat bilan quyidagi ketma-ketlikni sinang. Bu toza JS — «Ishga tushir» bilan ishlaydi.

Yechim
js
function createKeyStore(clock, ttlMs = 1000) {
  const entries = new Map();
  return {
    begin(key, print) {
      const e = entries.get(key);
      if (e && e.expires <= clock()) entries.delete(key);
      const cur = entries.get(key);
      if (!cur) {
        entries.set(key, { print, expires: clock() + ttlMs });
        return { kind: "new" };
      }
      if (cur.print !== print) return { kind: "other-body" };
      if (!cur.response) return { kind: "running" };
      return { kind: "done", response: cur.response };
    },
    finish(key, response) {
      const e = entries.get(key);
      if (e) e.response = response;
    },
    remove(key) {
      entries.delete(key);
    },
  };
}

let now = 0;
const store = createKeyStore(() => now);
console.log(store.begin("k1", "A").kind);
console.log(store.begin("k1", "A").kind);
store.finish("k1", { status: 201, body: { id: 1 } });
console.log(store.begin("k1", "A").response.body.id);
console.log(store.begin("k1", "B").kind);
now = 1500;
console.log(store.begin("k1", "B").kind);
store.remove("k1");
console.log(store.begin("k1", "C").kind);

Konsolda:

text
new
running
1
other-body
new
new

TTL o'tgach (now = 1500) kalit eskirgan — B tana bilan ham "new". remove dan keyin ham "new".

3-mashq (qiyin): qayta uruvchi mijoz

postWithRetry(url, body, { attempts, timeoutMs, fetchFn }) yozing. Kalit bir marta yaratilsin. TimeoutError yoki 409 bo'lsa — qayta urinsin (409 da Retry-After soniyani kutsin, testda ×10 ms); 2xx yoki boshqa 4xx — natijani qaytarsin. Har urinishda yuborilgan kalitni yozib boring. Soxta fetch bilan sinang: 1-urinish timeout, 2-urinish 409, 3-urinish 201.

Yechim
js
async function postWithRetry(url, body, opts) {
  const { attempts, timeoutMs, fetchFn } = opts;
  const sleepMs = opts.sleepMs ?? ((s) => s * 10);
  const key = crypto.randomUUID(); // bir marta — niyat uchun
  for (let i = 1; i <= attempts; i++) {
    try {
      const res = await fetchFn(url, {
        method: "POST",
        headers: { "idempotency-key": key },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(timeoutMs),
      });
      if (res.status !== 409) return res;
      const wait = Number(res.headers.get("retry-after") ?? 1);
      await new Promise((ok) => setTimeout(ok, sleepMs(wait)));
    } catch (error) {
      if (error.name !== "TimeoutError") throw error;
    }
  }
  throw new Error(`${attempts} urinishdan keyin ham javob yo'q`);
}

const seenKeys = [];
let call = 0;
async function fakeFetch(url, init) {
  seenKeys.push(init.headers["idempotency-key"]);
  call++;
  if (call === 1) {
    const e = new Error("timeout");
    e.name = "TimeoutError";
    throw e;
  }
  if (call === 2) {
    const headers = { "retry-after": "1" };
    return new Response("", { status: 409, headers });
  }
  return new Response('{"id":1}', { status: 201 });
}

const res = await postWithRetry("/tolovlar", { summa: 100000 }, {
  attempts: 4, timeoutMs: 100, fetchFn: fakeFetch,
});
console.log(res.status, await res.text());
console.log(seenKeys.length, new Set(seenKeys).size);

Konsolda:

text
201 {"id":1}
3 1

Uch urinish — bitta kalit (Set hajmi 1). Kalit sikl ichida yaratilsa, oxirgi qator 3 3 bo'lardi.

4-mashq: API qadami

O'z vazifalar-api ingizga src/umumiy/idempotentlik.ts ni qo'shing va /vazifalar ga ulang. CORS allowedHeaders va exposedHeaders ni yangilang. «Natija» bo'limidagi uchta curl buyrug'ini takrorlang va natijani solishtiring. Kamida uchta test yozing: takror → bitta vazifa; boshqa tana → 422; parallel ikki so'rov (sekin soxta ombor) → 201 va 409. npm run check toza bo'lgach — holat 24 commit'i («Testlar va commit» bo'limidagi xabar bilan).

10. Real ishda

  • Stripe — eng mashhur namuna: har POST Idempotency-Key qabul qiladi (255 belgigacha), kalit kamida 24 soat yashaydi, boshqa parametrlar bilan qayta ishlatilsa xato. Stripe hujjatidagi "Idempotent requests" sahifasi — bu darsning amaliy ko'rinishi.
  • Click, Payme kabi O'zbekiston to'lov tizimlarida o'xshash g'oya tranzaksiya ID orqali: bitta transaction ikki marta "perform" qilinmaydi (to'lov UI darsi).
  • Ko'p serverli tizimda kalit ombori — Redis yoki Postgres jadvali (UNIQUE kalit). Taqsimlangan tizimlarda idempotentlik, timeout va qayta urinish birga ko'riladi — Idempotentlik va timeout budjeti darsida.
  • Intervyuda: "Mijoz POST'ni qayta yubordi, ikki buyurtma yaratildi — qanday oldini olasiz?" Javob: Idempotency-Key + iz + atomik "jarayonda" + saqlangan javob + TTL.

Xulosa

  • POST idempotent emas — timeout'dan keyin qayta yuborish ikkinchi resurs (to'lov) yaratadi.
  • Mijoz har niyat uchun bitta kalit yaratadi va har urinishda shuni yuboradi.
  • Server kalit + so'rov izini saqlaydi: yangi → bajaradi; jarayonda → 409; tayyor → saqlangan javob; boshqa tana → 422.
  • "Yo'q bo'lsa yoz" — atomik; 5xx da kalit o'chadi; uzilishda o'chmaydi (kanon xatosi).
  • Kalitlar TTL bilan (kanonda 24 soat) va son chegarasi bilan saqlanadi.

Keyingi dars: API versiyalash va deprecation — Problem Details buzgan frontend'ni hisobga olib, API'ni /v1 ga ko'chiramiz va eski yo'llarga Deprecation/Sunset qo'yamiz.

Manbalar

  • draft-ietf-httpapi-idempotency-key-header-08 (2026-10-22) — datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
  • RFC 9110 §9.2.2 "Idempotent Methods" (2022-06) — rfc-editor.org/rfc/rfc9110
  • Stripe API "Idempotent requests" — docs.stripe.com/api/idempotent_requests
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Idempotency-Key: POST so'rovini xavfsiz qayta yuborish va ikki marta to'lovdan himoya — IlmHamroh