# Apa Itu Idempotency? Retry Aman dan Idempotency Key

> Apa itu idempotency: method HTTP safe vs idempotent, kenapa retry membutuhkannya, cara kerja idempotency key, idempotent consumer, dan upsert.

URL: https://www.matthewswong.com/id/learn/system-design/idempotency-explained/

System design langkah demi langkah · Pelajaran 10 dari 11 · Ditinjau pada 18 Oktober 2026 · Oleh Matthews Wong (https://www.matthewswong.com/id/about/)

> Idempotency berarti menjalankan sebuah operasi dua kali punya efek yang sama dengan menjalankannya sekali. Ini penting karena jaringan bisa putus di tengah request: setelah timeout, klien tidak tahu apakah pekerjaannya sudah terjadi, jadi ia melakukan retry. GET, PUT, dan DELETE idempotent sejak definisinya; POST tidak, jadi API menambahkan idempotency key agar server memutar ulang jawaban pertamanya alih-alih mengulang pekerjaannya.

## Setelah pelajaran ini, Anda bisa

- Membedakan method safe dari method idempotent, dan tahu request mana yang boleh di-retry sendiri oleh klien setelah timeout.
- Merancang alur idempotency key: siapa yang membuat key, apa yang disimpan server, berapa lama, dan apa jawaban untuk duplikat yang masih diproses.
- Membuat consumer dan script aman dijalankan ulang dengan upsert, tabel pesan yang sudah diproses, dan write yang menyetel nilai alih-alih menambahkannya.

## Respons yang hilang, sebuah retry, dan idempotency key

Sebuah request pembayaran berhasil tetapi responsnya hilang; jika di-retry tanpa key, kartu ditagih dua kali, sedangkan dengan idempotency key server menyimpan jawaban pertamanya lalu memutarnya ulang, menjawab 409 untuk duplikat yang masih diproses, dan menolak key yang dipakai lagi dengan body berbeda.

1. **Kenali bagiannya.** Aplikasi checkout mengirim POST /charges ke payments API, yang mencatat setiap charge ke sebuah tabel. Balasannya kembali lewat jaringan. Penyimpanan key belum dipakai.
2. **Balasannya hilang.** API menagih kartu dan menjawab 201, tetapi jaringan menjatuhkan balasannya. Klien hanya melihat timeout, jadi ia tidak tahu apakah tagihannya sudah terjadi.

   *Tebak:* Klien mengirim POST yang sama lagi, tanpa tambahan apa pun. Apa yang dilakukan API? (Mengembalikan jawaban pertama / Menagih kartu sekali lagi / Menolaknya sebagai duplikat)
3. **Retry menagih dua kali.** Tanpa key, API tidak bisa membedakan retry dari order baru, jadi ia menagih lagi. POST tidak idempotent: dua kiriman identik, dua efek.
4. **Kirim key, simpan hasilnya.** Perbaikan: klien membuat satu idempotency key sebelum percobaan pertama. API mengklaim key itu, menagih, lalu menyimpan hasilnya di bawah key tersebut. Balasan ini juga hilang.

   ```
   Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
   ```
5. **Retry dapat jawaban tersimpan.** Retry membawa key yang sama. API menemukan k1 dan mengembalikan 201 yang tersimpan tanpa menagih lagi. Request sama, efek sama: endpoint ini sekarang idempotent.

   ```
   Idempotent-Replayed: true
   ```

   *Tebak:* Order baru mengirim key k2, lalu duplikat dengan k2 tiba saat yang pertama masih berjalan. Apa yang seharusnya diterima duplikat itu? (Tagihan kedua / Jawaban tersimpan milik k1 / Konflik: coba lagi nanti)
6. **Duplikat yang masih jalan.** Key diklaim sebagai sedang diproses sebelum pekerjaan dimulai. Duplikat yang tiba di tengah proses mendapat 409 Conflict dan mencoba lagi nanti, bukan berlomba dengan request pertama.

   ```
   HTTP/1.1 409 Conflict
   ```
7. **Key punya aturan.** Memakai k1 lagi dengan body berbeda adalah bug di klien: draft IETF menjawab 422, Resend 409. Key juga kedaluwarsa: Stripe menyimpannya minimal 24 jam.

   ```
   HTTP/1.1 422 Unprocessable Content
   ```

## Apa itu idempotency?

Sebuah operasi disebut idempotent jika menjalankannya dua kali punya efek yang sama dengan menjalankannya sekali. Menyetel saklar lampu ke posisi mati itu idempotent; membalik posisinya tidak. RFC 9110, spesifikasi HTTP, mendefinisikannya untuk request: sebuah method idempotent jika efek yang dimaksud di server dari beberapa request identik sama dengan efek dari satu request saja.

Dua kata dalam definisi itu yang paling penting. Efek, bukan respons: RFC 9110 mencatat bahwa mengulang request idempotent punya efek yang sama walaupun request pertama sudah berhasil, meski responsnya bisa berbeda. DELETE kedua untuk item yang sama tidak menemukan apa pun untuk dihapus, tetapi item itu tetap hilang. Yang dimaksud: server tetap bebas mencatat setiap request secara terpisah di log atau menyimpan riwayat revisi. Idempotency berbicara tentang apa yang diminta klien.

## Safe vs idempotent: method HTTP mana yang termasuk?

Method safe bersifat read-only sejak definisinya: klien tidak meminta dan tidak mengharapkan perubahan state di server. RFC 9110 menetapkan GET, HEAD, OPTIONS, dan TRACE sebagai method safe. Setiap method safe juga idempotent, begitu pula PUT dan DELETE, yang mengubah state tetapi berakhir di state yang sama jika diulang. POST tidak termasuk keduanya. RFC 5789 menyebut PATCH juga tidak safe dan tidak idempotent, sambil mencatat bahwa PATCH bisa ditulis supaya idempotent.

Perbedaan PUT dan POST ada pada niatnya. PUT membawa state baru yang lengkap untuk resource yang disebut klien, jadi mengirimnya dua kali tetap menghasilkan state yang sama. POST menyerahkan datanya ke resource untuk diproses menurut aturannya sendiri, misalnya membuat order baru, jadi setiap kiriman bisa membuat order lain. RFC 9110 sendiri menarik garisnya: service yang memilihkan URI untuk resource baru atas nama klien sebaiknya memakai POST, bukan PUT.

## Kenapa retry membuat idempotency jadi soal kebenaran data?

Timeout itu ambigu. Request-nya mungkin tidak pernah sampai, server mungkin berhenti di tengah jalan, atau pekerjaannya sudah selesai dan hanya balasannya yang hilang. Panduan error Stripe menyatakannya dengan jelas: koneksi yang pulih tidak memberi tahu Anda apakah request awal berhasil. Klien pun tinggal punya dua pilihan buruk, menyerah dan mungkin kehilangan order, atau retry dan mungkin mengerjakannya dua kali.

RFC 9110 menetapkan aturan lanjutannya. Klien boleh mengulang request idempotent secara otomatis setelah koneksi gagal. Klien sebaiknya tidak me-retry request non-idempotent secara otomatis, kecuali ia tahu request itu memang idempotent, atau tahu request awalnya tidak pernah dijalankan, dan proxy sama sekali tidak boleh me-retry request non-idempotent. Pelajaran reliability (pelajaran 5) membahas kapan dan seberapa sering retry; pelajaran ini membuat retry-nya aman, sehingga percobaan at-least-once tetap menghasilkan efek tepat satu kali.

## Bagaimana idempotency key bekerja, dan apa yang disimpan server?

Klien membuat satu key unik untuk satu operasi, sebelum percobaan pertama, lalu mengirimnya di setiap retry lewat header Idempotency-Key. Pilihan yang umum adalah UUID acak: draft IETF menyarankan UUID atau identifier acak sejenis, Stripe menyarankan UUID V4, dan crypto.randomUUID() bisa membuatnya di browser maupun di Node. Buat key per operasi, jangan per percobaan, karena key baru terlihat seperti request baru. Stripe juga menyarankan menurunkan key dari sesuatu yang sudah dimiliki pengguna, misalnya ID keranjang belanja, untuk mencegah double submit.

Server menyimpan catatan kecil per key: fingerprint dari body request, status pekerjaannya (masih diproses atau selesai), serta status code dan body yang dijawabnya. Pada request pertama, server mengklaim key, mengerjakan tugasnya, lalu menyimpan hasilnya. Pada retry, server mengembalikan hasil tersimpan tanpa mengerjakannya lagi, dan Stripe menandai balasan seperti ini dengan Idempotent-Replayed: true. Stripe baru menyimpan hasil setelah endpoint mulai berjalan, jadi request yang ditolak validasi bisa langsung di-retry, sedangkan 500 yang sudah tersimpan akan diputar ulang sebagai 500.

Key tidak disimpan selamanya, dan server sebaiknya mengumumkan berapa lama ia menyimpannya. Stripe menyimpannya minimal 24 jam dan bisa menghapusnya setelah itu, jadi key yang dipakai lagi belakangan bisa dijalankan sebagai request baru; Resend menyimpannya selama 24 jam. Ada dua kasus tepi yang perlu jawaban. Duplikat yang tiba saat request pertama masih berjalan mendapat 409 Conflict menurut draft IETF, dan panduan Stripe menyarankan menunggu lalu me-retry dengan key yang sama. Key yang dipakai lagi dengan body berbeda mendapat 422 menurut draft; Resend menjawab kedua kasus dengan 409 dan nama error yang berbeda.

Nama header ini berasal dari draft working group HTTPAPI di IETF. Per 2026-10-18, datatracker IETF mencatatnya sebagai Internet-Draft yang sudah kedaluwarsa, dengan revisi terakhir versi 07 pada 2025-10-15, jadi ia bukan standar. Meski begitu, Stripe dan Resend memakai nama header yang sama, sehingga nama itu pilihan default yang aman untuk API Anda sendiri.

## Apa itu at-least-once delivery dan idempotent consumer?

Message queue menghadapi masalah yang sama dari sisi lain. Panduan reliability RabbitMQ menyebut acknowledgement menjamin at-least-once delivery, dan tanpanya hanya at-most-once yang dijamin. Consumer sebaiknya meng-ack pesan hanya setelah pekerjaannya selesai. Jika channel atau koneksinya tertutup lebih dulu, setiap delivery yang belum di-ack akan dimasukkan lagi ke antrean dan datang kembali dengan flag redelivered menyala. Publisher yang kehilangan confirm juga bisa mengirim satu pesan dua kali.

Jadi duplikat itu hal biasa, dan RabbitMQ menyarankan merancang consumer agar idempotent alih-alih melakukan deduplikasi secara eksplisit. Flag redelivered hanya petunjuk: jika false, pesannya baru; jika true, consumer mungkin sudah pernah melihatnya. Resep yang umum adalah pola Idempotent Consumer di microservices.io: di dalam transaksi database yang sama dengan perubahan bisnisnya, catat ID setiap pesan yang sudah diproses. Pesan yang dikirim ulang akan menemukan ID-nya sudah tercatat, lalu consumer melewati pekerjaannya dan meng-ack pesan itu.

## Apa itu idempotency alami, dan di mana Anda sudah memakainya?

Idempotency termurah tidak butuh key sama sekali: operasi write yang menyetel sebuah state, bukan mengubahnya sebesar suatu jumlah. Setel stok menjadi 11 boleh dijalankan dua kali; kurangi satu tidak boleh. PUT mengganti sebuah resource, jadi aman diulang. Di SQL, upsert melakukan hal yang sama untuk insert: INSERT dengan ON CONFLICT DO NOTHING, di PostgreSQL dan di SQLite sejak 3.24.0, melewati baris yang unique key-nya sudah ada, dan ON CONFLICT DO UPDATE mengubah insert menjadi update. PostgreSQL menjamin hasil insert-atau-update yang atomik untuk DO UPDATE, bahkan saat konkurensinya tinggi.

Tool deklaratif idempotent sejak rancangannya, karena ia membandingkan state yang Anda deklarasikan dengan state yang ada. docker compose up hanya membuat ulang container sebuah service jika konfigurasi atau image-nya berubah setelah container itu dibuat, jadi up -d kedua tanpa perubahan apa pun membiarkan container yang sedang berjalan. Shell punya pembagian yang sama: chmod 600 menyetel mode secara absolut, mkdir -p tidak gagal jika direktorinya sudah ada, dan > mengganti isi file sedangkan >> menambahkan di akhir, jadi menjalankan ulang script yang memakai >> menggandakan setiap baris. Header scripts/setup-resend-templates.mjs di repo ini menyebut bahwa menjalankannya ulang akan memperbarui template: script itu mencarinya lewat alias yang tetap, bukan membuat template baru.

## Konfigurasi nyata di balik pelajaran ini

`app/api/contact/route.ts:148-154`

```typescript
  try {
    // Keyed by the payload, so a retry of the same message (a visitor
    // resubmitting after a lost reply) replays the first send instead of
    // delivering it twice. See lib/contact-idempotency.ts.
    const { error } = await getResend(apiKey).emails.send(payload, {
      idempotencyKey: contactIdempotencyKey(payload),
    });
```

Sampai 2026-10-18, inilah POST non-idempotent yang diperingatkan pelajaran ini. Panggilan send tidak memberi Resend key apa pun, SDK mengubah koneksi yang putus menjadi nilai error yang sama dengan penolakan, dan form meminta pengunjung mencoba lagi, jadi balasan yang hilang berarti email kedua: terhadap tiruan lokal Resend yang menjatuhkan balasan pertamanya, dua kali kirim menghasilkan dua pengiriman. Sekarang panggilannya membawa key, dan tes yang sama menghasilkan satu pengiriman dan satu replay.

`lib/contact-idempotency.ts:27-32`

```typescript
export function contactIdempotencyKey(payload: object): string {
  const digest = createHash("sha256")
    .update(JSON.stringify(payload))
    .digest("hex");
  return `${CONTACT_KEY_PREFIX}${digest}`;
}
```

Key-nya adalah SHA-256 dari payload persisnya: pengirim, penerima, reply-to, template, dan setiap variabel. Retry yang identik menghasilkan string yang sama, jadi membawa key yang sama tanpa ada yang perlu diingat form, bahkan setelah halaman dimuat ulang. Pesan yang diubah menghasilkan hash berbeda dan menjadi email baru. Dan sebuah key tidak akan pernah bertemu payload yang berbeda, jadi 409 dari Resend untuk kasus itu tidak mungkin terjadi.

`app/[locale]/(pages)/contact/page.tsx:279-286`

```typescript
      setShowSuccessModal(true);
      resetWizard();
    } catch (error) {
      console.error(error);
      setShowErrorModal(true);
    } finally {
      setIsSubmitting(false);
    }
```

Dari sinilah retry berasal. Kegagalan apa pun, termasuk koneksi yang putus, masuk ke catch dan membuka dialog yang bilang pesannya tidak terkirim dan meminta pengunjung mencoba lagi. Hanya jalur sukses yang memanggil resetWizard, jadi pesan yang sudah diketik masih ada dan satu tekan tombol Send lagi mengirim pesan yang sama lagi. Route menurunkan key yang sama dari pesan itu, jadi retry tersebut me-replay pengiriman pertama. isSubmitting menonaktifkan tombol selama request berjalan, yang mencegah double click.

`.github/workflows/deploy.yml:226-244`

```yaml
            cat > .env <<'ENVEOF'
            GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}
            RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
            CONTACT_FROM_EMAIL=${{ vars.CONTACT_FROM_EMAIL }}
            CONTACT_TO_EMAIL=${{ vars.CONTACT_TO_EMAIL }}
            IMAGE=${{ needs.build-and-push.outputs.image }}
            APP_PORT=${{ vars.APP_PORT || 3002 }}
            ENVEOF
            chmod 600 .env

            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
            docker compose pull
            # Normal path updates the container in place. On the first cutover the
            # pre-existing (non-compose) container holds the name — remove it once,
            # then compose adopts the name going forward.
            docker compose up -d --remove-orphans || {
              docker rm -f matthewswong-portfolio 2>/dev/null || true
              docker compose up -d --remove-orphans
            }
```

Baca langkah deploy ini sebagai script yang harus aman dijalankan dua kali, dan itulah yang membuat menjalankan ulang job setelah deploy yang setengah jalan tidak berbahaya. cat > .env menulis ulang seluruh file setiap kali, sedangkan >> akan menambahkan salinan kedua dari setiap baris. chmod 600 menyetel mode secara absolut. docker compose up -d hanya membuat ulang container jika konfigurasi atau image-nya berubah. Bahkan fallback-nya aman diulang: rm -f dengan || true tetap berhasil, ada atau tidak ada container lamanya.

`scripts/merge-blog-staging.mjs:12-23`

```javascript
 * Idempotent: a slug already in BLOG_POSTS_STATIC is skipped.
 *
 *   node scripts/merge-blog-staging.mjs [--dry-run]
 *
 * Design note — why this is plan-then-write rather than validate-then-write:
 * the previous version validated the staged CONTENT completely, then began
 * writing, and resolved each insertion anchor at write time. When a constant
 * moved module (REAL_IMAGE_SLUGS left blog-posts-static.ts), the third write
 * threw after messages/*.json and the post registry had already been updated —
 * a half-merged repo that a re-run would not fix, because by then the slug
 * existed in en.json and got skipped. Anchors are now resolved during
 * validation, so either every edit is possible or nothing is touched.
```

Idempotent dengan cara melewati: post yang sudah terdaftar tidak di-merge lagi. Catatan desainnya merekam bagaimana cara itu gagal. Satu write gagal setelah write sebelumnya sudah masuk, jadi penanda selesai sudah ada, run berikutnya melewati post itu, dan repo tertinggal setengah ter-merge. Perbaikannya adalah aturan di balik setiap job yang aman diulang: pastikan semua yang bisa gagal sudah diputuskan sebelum write pertama, sehingga job itu selesai sepenuhnya atau tidak tersentuh sama sekali.

## Yang sering rusak

- **Key baru di setiap percobaan.** Membuat key di dalam loop retry menjadikan setiap retry sebuah request baru, dan perlindungannya hilang. Buat key sekali per operasi, simpan bersama operasi yang belum selesai, lalu pakai ulang di setiap retry, restart proses, dan reload halaman. Panduan Stripe memperingatkan bahwa key baru bisa memulai operasi kedua.
- **Cek key lalu simpan, dalam dua langkah terpisah.** Dua duplikat yang tiba bersamaan sama-sama tidak menemukan catatan untuk key itu, sama-sama mengerjakan tugasnya, dan pelanggan ditagih dua kali. Klaim key lebih dulu dalam satu langkah atomik yang diputuskan oleh unique constraint, tandai sebagai sedang diproses, dan hanya request yang memenangkan klaim yang mengerjakan tugasnya. Yang lain mendapat 409.
- **Penanda selesai yang ditulis sebelum pekerjaan selesai.** Lewati-jika-sudah-ada hanya idempotent jika penandanya berarti seluruh job sudah selesai. Jika langkah berikutnya gagal, run ulang akan selamanya melewati job yang setengah jadi, seperti yang pernah terjadi pada merge script repo ini. Tulis penandanya dalam transaksi yang sama dengan pekerjaannya, atau putuskan semua yang bisa gagal sebelum write pertama.
- **Menganggap 500 sebagai izin untuk mencoba dari awal.** Status 500 itu tidak pasti: operasinya mungkin sudah menimbulkan efek. Panduan Stripe menyarankan untuk tidak me-retry dengan key baru sampai hasil request awal jelas, dan berhenti me-retry operasi yang belum jelas setelah jendela 24 jam lalu merekonsiliasinya.
- **Key yang dibuat dari isi pesan menggabungkan pesan yang identik.** Key kontak situs ini adalah hash dari pesannya, jadi dua pesan yang memang identik dan dikirim dalam 24 jam milik Resend hanya tiba sekali. Untuk form kontak, justru itu tujuannya. Untuk pembayaran itu salah: dua order identik adalah dua order, jadi di sana klien membuat key sekali per operasi yang dimaksud, misalnya crypto.randomUUID() yang disimpan sampai sukses dan dihapus saat input berubah.

### Method HTTP menurut RFC 9110 dan RFC 5789: safe, idempotent, dan apa yang boleh dilakukan klien setelah timeout

| Method | Safe | Idempotent | Setelah timeout |
| --- | --- | --- | --- |
| GET, HEAD, OPTIONS | Ya | Ya | Retry otomatis |
| PUT | Tidak | Ya | Retry: menyetel state yang sama lagi |
| DELETE | Tidak | Ya | Retry: resource-nya tetap terhapus |
| POST | Tidak | Tidak | Retry hanya dengan idempotency key |
| PATCH | Tidak | Tidak, kecuali patch-nya dirancang begitu | Retry hanya jika idempotent atau memakai key |

## Coba sendiri

Anda butuh Node 22 atau lebih baru, curl, dan sqlite3 versi command line (sudah terpasang di macOS; di Linux, pasang lewat package manager). Server pembayaran mainan di 127.0.0.1:4242 butuh 1,5 detik per charge, dan tidak ada API pembayaran atau email sungguhan yang terlibat.

1. Simpan server ini sebagai server.mjs dan jalankan dengan node server.mjs di terminal tersendiri. Server mencetak jumlah charge setiap kali ada charge baru.

   ```bash
   // server.mjs: a toy payments API. Each charge takes 1.5 s. With an
   // Idempotency-Key header, the first result is stored and replayed.
   import { createHash } from "node:crypto";
   import http from "node:http";
   
   const charges = [];
   const keys = new Map(); // key -> { fingerprint, done, status, body }
   const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
   
   function reply(res, status, body, headers = {}) {
     res.writeHead(status, { "Content-Type": "application/json", ...headers });
     res.end(JSON.stringify(body) + "\n");
   }
   
   http
     .createServer(async (req, res) => {
       if (req.method === "GET") return reply(res, 200, { charges: charges.length });
       let raw = "";
       for await (const chunk of req) raw += chunk;
       const key = req.headers["idempotency-key"];
       const fingerprint = createHash("sha256").update(raw).digest("hex");
       if (key) {
         const seen = keys.get(key);
         if (seen && seen.fingerprint !== fingerprint)
           return reply(res, 422, { error: "key reused with a different body" });
         if (seen && !seen.done)
           return reply(res, 409, { error: "a request with this key is in progress" });
         if (seen) return reply(res, seen.status, seen.body, { "Idempotent-Replayed": "true" });
         keys.set(key, { fingerprint, done: false }); // claim the key BEFORE the work
       }
       await sleep(1500); // the slow part, like a call to a card network
       const charge = { id: `ch_${charges.length + 1}`, body: raw };
       charges.push(charge);
       if (key) keys.set(key, { fingerprint, done: true, status: 201, body: charge });
       console.log("charges so far:", charges.length);
       reply(res, 201, charge);
     })
     .listen(4242, "127.0.0.1", () => console.log("listening on 127.0.0.1:4242"));
   ```
2. Hilangkan sebuah balasan dengan sengaja. curl -m 1 menyerah setelah satu detik, sebelum charge 1,5 detik itu menjawab, jadi curl mencetak timeout (error 28) padahal server sudah menagih. Retry POST yang sama lalu baca hitungannya: 2 charge untuk satu order.

   ```bash
   curl -sS -m 1 -X POST http://127.0.0.1:4242/charges -d 'order=A-1'
   curl -sS -X POST http://127.0.0.1:4242/charges -d 'order=A-1'
   curl -sS http://127.0.0.1:4242/charges
   ```
3. Ulangi dengan idempotency key yang dibuat sekali, sebelum percobaan pertama. Panggilan pertama timeout lagi; retry-nya mendapat 201 yang tersimpan dengan Idempotent-Replayed: true, dan hitungannya naik satu, bukan dua.

   ```bash
   KEY=$(node -p "crypto.randomUUID()")
   curl -sS -m 1 -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=B-1'
   sleep 1
   curl -sS -i -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=B-1'
   curl -sS http://127.0.0.1:4242/charges
   ```
4. Kirim duplikat saat request pertama masih berjalan. Duplikatnya langsung mencetak 409; request aslinya mencetak 201 saat selesai.

   ```bash
   KEY=$(node -p "crypto.randomUUID()")
   curl -sS -w '%{http_code}\n' -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=C-1' &
   sleep 0.2
   curl -sS -w '%{http_code}\n' -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=C-1'
   wait
   ```
5. Di terminal yang sama, pakai lagi key tadi dengan body berbeda. Server menolak dengan 422, sesuai saran draft IETF, alih-alih memutar ulang jawaban untuk request yang berbeda.

   ```bash
   curl -sS -w '%{http_code}\n' -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=C-2'
   ```
6. Sekarang idempotency alami di SQLite. Upsert menyisipkan sekali (changes 1) dan tidak melakukan apa-apa di kali kedua (changes 0). Mengurangi dua kali menyisakan 10; menyetel 11 dua kali tetap 11.

   ```bash
   sqlite3 :memory: <<'SQL'
   CREATE TABLE processed (message_id TEXT PRIMARY KEY);
   CREATE TABLE stock (sku TEXT PRIMARY KEY, qty INTEGER);
   INSERT INTO stock VALUES ('mug', 12);
   -- a redelivered message, handled twice
   INSERT INTO processed VALUES ('msg-42') ON CONFLICT (message_id) DO NOTHING;
   SELECT changes();
   INSERT INTO processed VALUES ('msg-42') ON CONFLICT (message_id) DO NOTHING;
   SELECT changes();
   -- add-by-amount twice, then set-a-value twice
   UPDATE stock SET qty = qty - 1 WHERE sku = 'mug';
   UPDATE stock SET qty = qty - 1 WHERE sku = 'mug';
   SELECT qty FROM stock;
   UPDATE stock SET qty = 11 WHERE sku = 'mug';
   UPDATE stock SET qty = 11 WHERE sku = 'mug';
   SELECT qty FROM stock;
   SQL
   ```
7. Hentikan server dengan Ctrl+C di terminalnya. Semua datanya hanya di memori, jadi tidak ada yang tertinggal.

## Kuis singkat

1. **Method HTTP mana yang idempotent tetapi tidak safe?**
   A. PUT
   B. GET
   C. POST
   D. PATCH
   Jawaban: A. PUT mengubah state, jadi tidak safe, tetapi mengulangnya menghasilkan state yang sama, jadi idempotent. GET safe sehingga otomatis idempotent; POST tidak keduanya, dan PATCH juga tidak kecuali patch-nya ditulis agar idempotent.

2. **POST dengan idempotency key k1 timeout, lalu klien melakukan retry. Kapan klien sebaiknya membuat key baru?**
   A. Di setiap retry, supaya server melihat request baru
   B. Hanya untuk operasi baru, tidak pernah untuk retry operasi ini
   C. Setelah 409, karena k1 sekarang terkunci
   D. Setelah timeout, karena k1 mungkin sudah terpakai
   Jawaban: B. Key menamai operasinya, jadi setiap retry untuk operasi itu membawa k1. 409 berarti percobaan pertama masih berjalan: tunggu lalu retry dengan key yang sama, karena key baru bisa memulai operasi kedua.

3. **Sebuah consumer RabbitMQ menulis baris order dari sebuah pesan, lalu crash sebelum meng-ack. Apa yang terjadi berikutnya?**
   A. Pesannya hilang, dan order-nya aman
   B. RabbitMQ menghapus duplikatnya untuk Anda
   C. Pesannya dikirim ulang, jadi consumer harus melewati order yang sudah ditulisnya
   D. Pesannya menunggu sampai broker di-restart
   Jawaban: C. Delivery yang belum di-ack dimasukkan lagi ke antrean saat channel atau koneksi consumer tertutup, jadi pengirimannya at least once. Mencatat ID pesan dalam transaksi yang sama dengan order membuat pesan yang dikirim ulang menemukannya lalu melewatinya.

## Pikirkan dulu

- **Kenapa DELETE idempotent walaupun panggilan kedua bisa menjawab berbeda?** Idempotency berbicara tentang efek yang dimaksud di server, bukan responsnya. Setelah satu atau dua kali DELETE, resource-nya tetap hilang; RFC 9110 menyebut respons untuk pengulangan bisa berbeda.
- **Apa yang disimpan server untuk setiap idempotency key, dan kapan?** Fingerprint dari body, status pekerjaannya, serta status dan body yang dijawabnya. Server mengklaim key sebagai sedang diproses sebelum mengerjakan tugasnya, sehingga duplikat yang masih jalan mendapat 409, lalu menyimpan hasilnya saat pekerjaan selesai.
- **Script deploy menambahkan isi ke .env dengan >>. Apa yang terjadi saat deploy dijalankan lagi, dan bagaimana perbaikannya?** Setiap baris muncul dua kali, dan file-nya membesar di setiap run. Tulis ulang seluruh file dengan >, seperti yang dilakukan deploy situs ini dengan cat > .env, sehingga setiap run menghasilkan file yang sama.
- **Kapan idempotency alami sudah cukup, dan kapan Anda butuh key?** Jika request menyebut resource-nya dan membawa state lengkapnya, seperti PUT, operasi set, atau upsert pada unique key alami, pengulangan akan berakhir di hasil yang sama. Jika setiap request membuat sesuatu yang baru, misalnya charge atau email, hanya key pilihan klien yang bisa membedakan retry dari request baru.

## Pelajari lebih dalam

- [Idempotency Key: Mendesain API yang Tahan Retry](https://www.matthewswong.com/id/blog/idempotency-keys-api-design/)
- [Pola Idempotent Consumer untuk Message Queue](https://www.matthewswong.com/id/blog/idempotent-consumer-pattern-message-queue/)
- [Merancang Webhook yang Andal: Retry, Signing, dan Replay](https://www.matthewswong.com/id/blog/webhooks-reliable-delivery-design/)

## Sumber

- [RFC 9110: HTTP Semantics (method safe dan idempotent, PUT vs POST)](https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods)
- [RFC 5789: method PATCH untuk HTTP](https://www.rfc-editor.org/rfc/rfc5789.html)
- [Datatracker IETF: header HTTP Idempotency-Key (draft kedaluwarsa, revisi 07)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)
- [Referensi API Stripe: idempotent request](https://docs.stripe.com/api/idempotent_requests)
- [Dokumentasi Stripe: penanganan error lanjutan (error jaringan, 409, jendela retry)](https://docs.stripe.com/error-low-level)
- [Dokumentasi Resend: idempotency key](https://resend.com/docs/dashboard/emails/idempotency-keys)
- [Dokumentasi RabbitMQ: panduan reliability (at-least-once, consumer idempotent)](https://www.rabbitmq.com/docs/reliability)
- [Dokumentasi RabbitMQ: consumer acknowledgement dan publisher confirm](https://www.rabbitmq.com/docs/confirms)
- [microservices.io: pola Idempotent Consumer](https://microservices.io/patterns/communication-style/idempotent-consumer.html)
- [Dokumentasi PostgreSQL: INSERT, ON CONFLICT](https://www.postgresql.org/docs/current/sql-insert.html)
- [Dokumentasi SQLite: UPSERT](https://sqlite.org/lang_upsert.html)
- [Dokumentasi Docker: docker compose up](https://docs.docker.com/reference/cli/docker/compose/up/)
- [POSIX: chmod (mode absolut)](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/chmod.html)
- [POSIX: mkdir -p](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/mkdir.html)
- [POSIX Shell Command Language: redirect dan append output](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html)
- [MDN: Crypto.randomUUID()](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID)

---

- HTML version: https://www.matthewswong.com/id/learn/system-design/idempotency-explained/
- English: https://www.matthewswong.com/en/learn/system-design/idempotency-explained/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
