๐ 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
- Baru โ proses normal
- In progress (request pertama masih jalan) โ 409 Conflict atau tunggu sebentar lalu retry. Jangan eksekusi paralel.
- Completed โ replay response tersimpan (termasuk status code asli)
- 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:
-
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.
-
Response disimpan di luar transaksi bisnis โ charge berhasil, tapi proses crash sebelum
UPDATE status='completed'. Retry โ key masihin_progressโ 409 selamanya. Butuh recovery job yang menandai keyin_progressyang lebih tua dari X menit sebagaifailedatau mengecek status transaksi aktual. -
TTL terlalu pendek โ mobile client offline 3 hari, retry setelah key expired โ double charge. TTL harus mengikuti jendela retry nyata, bukan default.
-
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.
-
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
-
Client mengirim
POST /paymentsdenganIdempotency-Key: X. Server memproses, charge berhasil, tapi proses crash sebelum menyimpan response. Client retry dengan keyX. Apa yang terjadi dengan desain di atas, dan bagaimana kamu memperbaikinya? -
Kenapa
request_hashpenting? Beri contoh skenario di mana ketiadaannya menyebabkan data corruption. -
Kamu punya endpoint
POST /ordersdengan uniqueorder_iddari client. Apakah kamu masih butuh tabel idempotency key terpisah? Jelaskan trade-off-nya.
๐ Poin Kunci
- Timeout โ gagal. Timeout = status tidak diketahui. Ini akar dari kebutuhan idempotency.
- Idempotency key butuh 4 state (
baru,in_progress,completed,failed) + atomic claim (INSERT ... ON CONFLICT DO NOTHING), bukan sekadar cache lookup. - Simpan response satu transaksi dengan efek bisnis. Kalau tidak, crash di tengah = key nyangkut.
request_hashmencegah key reuse dengan payload berbeda โ 422.- Scope key ke tenant/api_key. TTL mengikuti jendela retry nyata (umum: 24 jam).
- Idempotency key โ exactly-once. Client yang lupa kirim key sama tetap bisa double-execute.
- Kalau domain punya natural unique key, pakai itu dulu โ tabel key terpisah hanya kalau perlu menyimpan response untuk replay.
โก๏ธ Eksplorasi Lanjut
- Outbox pattern โ bagaimana memastikan event publish + DB write atomik (masalah serumpun: "dua sistem, satu transaksi")
- Saga pattern โ kompensasi ketika langkah kedua gagal setelah langkah pertama commit; idempotency key adalah prasyarat saga yang benar
- RFC 9110 ยง9.2.2 โ definisi resmi idempotent method
- Stripe API docs โ Idempotent requests โ referensi implementasi paling matang di industri
- Distributed transaction vs idempotency โ kenapa kebanyakan sistem produksi memilih idempotency + retry daripada 2PC