โ† semua materi
05:00 Arsitektur ยท 07 Nov 2026 ยท 05:01

๐Ÿ“š Idempotency Key & Retry-Safe API Design

๐ŸŽฏ Tujuan Belajar

Setelah sesi ini kamu bisa: - Tentukan kapan butuh idempotency key, kapan cukup pakai HTTP method semantics - Desain storage idempotency yang benar (scope, TTL, concurrency) - Kenali kenapa Idempotency-Key header saja tidak cukup โ€” harus ada state machine di belakangnya - Debug kasus "duplicate charge" yang lolos dari retry-safe design


๐Ÿง  Penjelasan Inti

Idempotency = operasi yang dijalankan N kali menghasilkan state akhir sama dengan dijalankan 1 kali. Bukan "tidak ada efek", tapi "efek tidak bertambah".

Kenapa ada: jaringan itu unreliable. Client kirim POST โ†’ server proses โ†’ response hilang di network โ†’ client timeout โ†’ client retry โ†’ dua kali efek. Ini bukan bug client, ini hukum fisika distributed system. Timeout tidak berarti gagal; timeout berarti status tidak diketahui (unknown outcome). Itu akar masalahnya.

Dua level yang sering dicampur:

Level Contoh Dijamin oleh
Protocol-level idempotency GET, PUT, DELETE Spesifikasi HTTP (RFC 9110)
Application-level idempotency POST /payments, POST /orders Kamu โ€” harus dibangun sendiri

PUT /users/42 {name:"X"} idempoten karena hasilnya sama berapa kali pun dikirim. POST /payments tidak idempoten โ€” setiap POST = transaksi baru. Di sinilah idempotency key masuk.

Idempotency key = token unik dari client yang menandai "ini request yang sama, bukan request baru". Server menyimpan hasil request pertama, dan untuk request berikutnya dengan key sama, mengembalikan hasil yang sama tanpa mengeksekusi ulang.


โš™๏ธ Cara Kerja Internal

Alur lengkap

Client                          Server
  |                                |
  |-- POST /payments ------------->|
  |   Idempotency-Key: abc123      |
  |                                |-- 1. INSERT key (status=in_progress)
  |                                |      UNIQUE constraint โ†’ kalau duplikat, lihat state
  |                                |-- 2. eksekusi bisnis logic
  |                                |-- 3. simpan response (status=completed, body, status_code)
  |<-- 201 {payment_id} -----------|
  |                                |
  |  (network drop / timeout)      |
  |                                |
  |-- POST /payments ------------->|  (retry, key sama)
  |   Idempotency-Key: abc123      |
  |                                |-- key ditemukan, status=completed
  |<-- 201 {payment_id} (replay) --|  (tidak eksekusi ulang)

Empat state yang wajib ada

  1. Baru โ†’ proses normal
  2. In progress (request pertama masih jalan) โ†’ 409 Conflict atau tunggu sebentar lalu retry. Jangan eksekusi paralel.
  3. Completed โ†’ replay response tersimpan (termasuk status code asli)
  4. Failed โ†’ tergantung kebijakan: replay error, atau izinkan retry dengan key sama

Poin nomor 2 yang paling sering hilang. Tanpa state in_progress, dua request paralel dengan key sama akan dua-duanya lolos cek "key belum ada" lalu dua-duanya eksekusi. Race condition klasik.

Mekanisme konkret (Postgres)

CREATE TABLE idempotency_keys (
  key             text PRIMARY KEY,          -- atau (scope, key)
  request_hash    text NOT NULL,             -- SHA-256 dari body
  status          text NOT NULL,             -- in_progress | completed | failed
  response_code   int,
  response_body   jsonb,
  created_at      timestamptz DEFAULT now(),
  expires_at      timestamptz NOT NULL
);

Alur atomik:

-- 1. Klaim key. Kalau ada yang sudah pegang, row_count = 0 โ†’ bukan kita.
INSERT INTO idempotency_keys (key, request_hash, status, expires_at)
VALUES ($1, $2, 'in_progress', now() + interval '24 hours')
ON CONFLICT (key) DO NOTHING;
-- row_count == 0 โ†’ SELECT existing row, branch by status

-- 2. Eksekusi bisnis logic + tulis response dalam SATU transaksi
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 42;
  INSERT INTO payments (...) VALUES (...);
  UPDATE idempotency_keys
     SET status='completed', response_code=201, response_body=$3
   WHERE key=$1;
COMMIT;

Kritis: penyimpanan response harus satu transaksi dengan efek bisnis. Kalau tidak, kamu bisa punya "sudah charge tapi key masih in_progress" โ†’ retry โ†’ charge lagi.

request_hash โ€” jangan dilewatkan

Kalau client kirim key sama dengan body berbeda, itu client bug. Server harus 422 Unprocessable Entity, bukan diam-diam pakai response lama. Tanpa hash, kamu akan mengembalikan response untuk request yang berbeda โ€” data corruption yang halus dan sulit dilacak.

Scope key

Key harus di-scope minimal ke: (api_key / tenant, endpoint, key). Kalau global, tenant A bisa "mencuri" replay response tenant B dengan menebak key. Ini bukan teori โ€” ini pernah jadi bug nyata di beberapa payment gateway.

TTL

Key tidak boleh hidup selamanya. Praktik umum: 24 jam (Stripe pakai 24 jam). Lebih lama = tabel membengkak. Lebih pendek = retry yang telat (misal client offline 2 hari) akan double-execute. Pilih TTL berdasarkan jendela retry maksimum client, bukan sembarang angka.


๐Ÿ—๏ธ Pseudocode โ€” handler yang benar

def handle_payment(req):
    key = req.headers.get("Idempotency-Key")
    if not key:
        return 400, {"error": "Idempotency-Key required"}

    body_hash = sha256(canonical_json(req.body))

    # Atomic claim
    claimed = db.execute("""
        INSERT INTO idempotency_keys (key, request_hash, status, expires_at)
        VALUES (%s, %s, 'in_progress', now() + interval '24 hours')
        ON CONFLICT (key) DO NOTHING
    """, [key, body_hash])

    if claimed.rowcount == 0:
        row = db.one("SELECT * FROM idempotency_keys WHERE key=%s", [key])

        if row.request_hash != body_hash:
            return 422, {"error": "Key reused with different payload"}

        if row.status == "in_progress":
            return 409, {"error": "Request in progress, retry shortly"}

        if row.status == "completed":
            return row.response_code, row.response_body   # replay

        # status == failed โ†’ kebijakan: izinkan retry
        db.execute("UPDATE idempotency_keys SET status='in_progress' WHERE key=%s", [key])

    # Eksekusi + simpan response, satu transaksi
    with db.transaction():
        result = charge_card(req.body)
        db.execute("""
            UPDATE idempotency_keys
               SET status='completed', response_code=%s, response_body=%s
             WHERE key=%s
        """, [201, json.dumps(result), key])
    return 201, result

โš–๏ธ Trade-off & Kapan JANGAN Dipakai

Ini bagian yang membedakan senior dari pemula.

Jangan pakai idempotency key kalau: - Operasi sudah idempoten secara natural (PUT, DELETE, GET). Menambah key = kompleksitas tanpa manfaat. - Operasi tidak punya efek samping berarti (misal update cache, log). Double-execute tidak merugikan. - Kamu bisa mencapai idempotency dengan natural key yang lebih murah: INSERT ... ON CONFLICT (order_id) DO NOTHING sudah idempoten tanpa tabel key terpisah. Kalau domain punya unique identifier alami, pakai itu dulu.

Biaya yang kamu tanggung: - Storage + TTL management โ€” tabel tumbuh, butuh cleanup job - Latency tambahan โ€” minimal 1 write ekstra (claim) + 1 write (complete) per request - Kontensi โ€” key yang sama dari banyak request paralel = row lock contention. Kalau traffic tinggi dan key sering sama, ini bisa jadi hotspot. - Penyimpanan response body โ€” response besar (misal PDF) harus disimpan atau disimpan referensinya saja - Kebijakan failed โ€” ambigu. Kalau charge gagal karena timeout ke bank, apakah retry aman? Itu pertanyaan domain, bukan infrastruktur. Idempotency key tidak menyelesaikannya untukmu.

Idempotency key โ‰  exactly-once. Ini yang paling sering salah dipahami. Client retry dengan key sama โ†’ server replay. Tapi kalau client lupa mengirim key yang sama (misal generate key baru saat retry), double-execute tetap terjadi. Idempotency key hanya bekerja kalau client disiplin. Server tidak bisa memaksa.


๐Ÿญ Contoh Produksi & Failure Mode

Stripe โ€” pionir pola Idempotency-Key. Dokumentasi mereka menyebut key disimpan 24 jam, dan key yang dipakai ulang dengan body berbeda ditolak. Ini jadi de-facto standar industri.

Failure mode nyata yang sering terjadi:

  1. Key di-generate di dalam retry loop โ€” client generate UUID baru tiap retry. Hasilnya: idempotency key tidak berguna sama sekali. Key harus di-generate sekali per logical operation, sebelum retry pertama.

  2. Response disimpan di luar transaksi bisnis โ€” charge berhasil, tapi proses crash sebelum UPDATE status='completed'. Retry โ†’ key masih in_progress โ†’ 409 selamanya. Butuh recovery job yang menandai key in_progress yang lebih tua dari X menit sebagai failed atau mengecek status transaksi aktual.

  3. TTL terlalu pendek โ€” mobile client offline 3 hari, retry setelah key expired โ†’ double charge. TTL harus mengikuti jendela retry nyata, bukan default.

  4. Idempotency hanya di layer HTTP, tidak di layer DB โ€” dua instance server di belakang load balancer, masing-masing punya cache in-memory key. Keduanya lolos. Key harus di shared storage (DB/Redis terpusat), bukan per-instance.

  5. Replay response tanpa status code asli โ€” request pertama return 201, replay return 200. Client yang memeriksa status code bisa salah interpretasi. Simpan dan replay status code asli.


โš ๏ธ Miskonsepsi Umum

Miskonsepsi Realita
"Idempotency key = exactly-once delivery" Hanya at-most-once effect kalau client disiplin. Bukan jaminan.
"Cukup cek if key exists, return cached" Tanpa state in_progress + atomic claim, race condition tetap terjadi.
"POST selalu butuh idempotency key" Hanya POST yang punya efek samping non-idempoten (charge, kirim email, buat resource).
"Retry otomatis aman kalau ada timeout" Timeout = unknown outcome. Retry tanpa idempotency = double effect.
"Idempotency key menggantikan unique constraint di DB" Tidak. Constraint DB adalah lapisan terakhir pertahanan; key adalah optimasi di depan. Pakai keduanya.

โœ… Cek Pemahaman

  1. Client mengirim POST /payments dengan Idempotency-Key: X. Server memproses, charge berhasil, tapi proses crash sebelum menyimpan response. Client retry dengan key X. Apa yang terjadi dengan desain di atas, dan bagaimana kamu memperbaikinya?

  2. Kenapa request_hash penting? Beri contoh skenario di mana ketiadaannya menyebabkan data corruption.

  3. Kamu punya endpoint POST /orders dengan unique order_id dari client. Apakah kamu masih butuh tabel idempotency key terpisah? Jelaskan trade-off-nya.


๐Ÿ”‘ Poin Kunci


โžก๏ธ Eksplorasi Lanjut