Layanan Anda crash di tengah jalan saat memproses permintaan POST /charge. Klien melihat timeout dan mencoba ulang. Sekarang Anda punya dua charge. Pelanggan marah. Database-nya konsisten. Logika bisnis Anda tidak.
Ini bukan edge case. Ini adalah perilaku default dari distributed system. Network menjatuhkan packet. Container di-OOM-killed di tengah permintaan. Load balancer mengembalikan 502 untuk permintaan yang sudah sampai ke backend. Jika API Anda mengasumsikan “Saya dapat error, jadi operasinya pasti tidak terjadi,” Anda sedang membangun pabrik bug.
Realitas at-least-once yang tidak bisa Anda hindari
HTTP secara default adalah at-least-once. TCP mencoba ulang packet yang hilang. HTTP client Anda mencoba ulang saat timeout. Infrastruktur Anda mencoba ulang saat 5xx. Setiap layer mengasumsikan layer sebelumnya mungkin gagal dan mengirim ulang.
Masalahnya bukan pada percobaan ulang. Masalahnya adalah operasi yang non-idempotent.
GET aman untuk dicoba ulang karena membaca dua kali sama dengan membaca sekali. POST /charge atau POST /orders tidak. Menjalankannya dua kali membuat dua resource. Menjalankannya nol kali kehilangan satu sale. Anda tidak bisa memilih “exactly once” di network yang tidak reliable. Anda hanya bisa memilih antara “at least once” dengan deduplication, atau “mungkin nol, mungkin dua” dengan data corruption.
Idempotency adalah cara Anda membuat “at least once” menjadi aman.
Bagaimana idempotency key sebenarnya bekerja
Stripe mempopulerkan pola ini, tapi idenya lebih tua. Klien menghasilkan key yang unik (sebuah UUID) dan mengirimkannya di header: Idempotency-Key: <uuid>. Server menyimpan tuple (key, request_body, response) sebelum mengeksekusi side effect. Jika key yang sama datang lagi, server mengembalikan response yang tersimpan tanpa menjalankan ulang operasinya.
Insight kuncinya: server harus menyimpan key sebelum melakukan pekerjaan, bukan setelahnya. Jika Anda menulis charge row dulu dan crash sebelum menyimpan key, percobaan ulang membuat charge kedua. Penyimpanan idempotency key dan business mutation harus atomic.
Dalam praktiknya, ini berarti salah satu dari dua hal:
-
Idempotency store dan business store berbagi database transaction. Anda insert ke
idempotency_keysdanchargesdalamBEGIN ... COMMITyang sama. Jika commit berhasil, keduanya ada. Jika gagal, keduanya tidak ada. -
Idempotency store-nya adalah business store-nya. Tabel
chargesAnda punya kolomclient_idempotency_keydengan constraintUNIQUE. Percobaan ulang gagal pada unique check, dan Anda mengembalikan row yang sudah ada.
Opsi 2 lebih sederhana dan merupakan apa yang seharusnya dipilih oleh kebanyakan tim sebagai awal.
Implementasi yang berfungsi dengan atomic storage
Berikut adalah server Python minimal tapi lengkap menggunakan SQLite. Idempotency key berada dalam transaction yang sama dengan business mutation. Jika server crash setelah COMMIT, percobaan ulang mengenai cache. Jika crash sebelum COMMIT, tidak ada yang di-persist dan percobaan ulang berjalan kembali dengan aman.
import sqlite3
import json
import uuid
DB_PATH = "/data/inventory.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS reservations (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
idempotency_key TEXT UNIQUE NOT NULL,
created_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS idempotency_responses (
key TEXT PRIMARY KEY,
response_body TEXT NOT NULL,
created_at INTEGER
)
""")
conn.commit()
return conn
def reserve_inventory(conn, idempotency_key, item_id, quantity):
# Step 1: Check for an existing response.
cursor = conn.execute(
"SELECT response_body FROM idempotency_responses WHERE key = ?",
(idempotency_key,)
)
row = cursor.fetchone()
if row:
return json.loads(row[0])
# Step 2: Do the work inside a single transaction.
conn.execute("BEGIN")
# Deduct inventory atomically.
conn.execute(
"UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
(quantity, item_id, quantity)
)
if conn.total_changes == 0:
conn.rollback()
return {"error": "insufficient inventory"}
# Record the reservation.
reservation_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
(reservation_id, item_id, quantity, idempotency_key)
)
# Cache the response for retries.
response = {"reservation_id": reservation_id, "status": "reserved"}
conn.execute(
"INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
(idempotency_key, json.dumps(response))
)
conn.commit()
return response
Tabel idempotency_responses adalah safety net. Permintaan pertama melakukan mutation, commit hasilnya, dan cache response-nya. Setiap permintaan berikutnya dengan key yang sama melewati pekerjaan dan mengembalikan JSON yang di-cache. Reservation dan cache entry ditulis dalam transaction yang sama, jadi keduanya either terlihat atau keduanya absent.
Di mana ini gagal: batas side effect
Idempotency key menangani duplicate request dari klien yang sama. Mereka tidak menangani concurrent duplicate request dari klien yang berbeda. Jika dua pengguna klik “Buy” pada item terakhir di stock, Anda masih butuh pessimistic locking atau optimistic concurrency control. Pengecekan available >= ? di contoh di atas adalah bentuk primitif dari ini, tapi sistem inventory yang nyata butuh lebih.
Masalah yang lebih besar adalah side effect di luar transaction. Jika Anda charge kartu via Stripe, kirim email via SendGrid, dan tulis ke database Anda, idempotency key hanya melindungi bagian database. Email-nya mungkin terkirim dua kali. Kartunya mungkin ter-charge dua kali jika window idempotency Stripe sendiri expired. Keamanan yang sesungguhnya membutuhkan setiap downstream system untuk berpartisipasi.
Itulah mengapa Stripe menerima Idempotency-Key-nya sendiri saat charge creation. Mereka melakukan deduplicate di layer mereka. Anda harus melakukan hal yang sama di layer Anda. Lewatkan key yang sama ke setiap idempotent downstream service. Untuk service yang tidak mendukungnya, wrap panggilan tersebut dalam local transaction atau terima risikonya.
Key collision, TTL, dan jebakan operasional lainnya
UUID4 memiliki 122 bit randomness. Probabilitas collision-nya negligible untuk volume yang realistis. Jangan gunakan sequential integer, timestamp, atau hashed request body sebagai key. UUID yang dihasilkan klien adalah industry standard untuk suatu alasan.
Penyimpanan key tumbuh selamanya kecuali Anda expire entry lama. Atur TTL: 24 jam adalah standar. Setelah itu, hapus key lama. Jika klien mencoba ulang setelah TTL, mereka mendapatkan duplicate. Dokumentasikan ini. Retry window dan TTL adalah business contract, bukan detail teknis.
Idempotency store harus setidaknya se-available API-nya. Jika Redis cache Anda down, Anda tidak bisa memverifikasi key. Beberapa tim fallback ke “asumsikan permintaan baru,” yang membuat duplicate saat outage. Yang lain menolak permintaan, yang lebih aman tapi membuat failure mode yang berbeda. Tidak ada free lunch di sini.
Sisi klien sama pentingnya
Idempotency key di sisi server tidak berguna jika klien tidak mengirimkannya. Setiap mutating request harus menghasilkan key di call site dan mencoba ulang saat timeout dengan key yang sama:
import uuid
import requests
def safe_post(url, payload, max_retries=3):
key = str(uuid.uuid4())
for attempt in range(max_retries):
try:
resp = requests.post(
url,
json=payload,
headers={"Idempotency-Key": key},
timeout=10,
)
return resp
except requests.Timeout:
if attempt == max_retries - 1:
raise
# Retry with the SAME key. The server deduplicates.
Key harus dihasilkan sekali per logical operation, bukan sekali per HTTP attempt. Jika Anda menghasilkan UUID baru di setiap percobaan ulang, Anda melewatkan intinya. Key adalah kontrak yang mengikat percobaan ulang bersama-sama.
Pertanyaan yang Sering Diajukan
Bagaimana jika kliennya adalah browser dan pengguna me-refresh halaman?
Refresh halaman membuat JavaScript context baru. Permintaan baru mendapatkan idempotency key baru kecuali Anda persist key ke localStorage atau URL. Kebanyakan tim tidak repot-repot untuk flow yang non-kritis. Untuk pembayaran, Anda harus.
Apakah permintaan GET harus menggunakan idempotency key?
Tidak. GET sudah aman menurut semantik HTTP. Idempotency key untuk method yang mengubah state: POST, PUT, PATCH, DELETE.
Bisakah saya menggunakan hash request body sebagai idempotency key?
Hanya jika body-nya deterministic dan tidak mengandung timestamp atau nilai random. Dalam praktiknya, UUID yang dihasilkan klien lebih sederhana dan lebih reliable.
Berapa lama saya harus menyimpan idempotency key?
Lebih lama dari retry window klien Anda yang paling lama. Jika klien mencoba ulang selama 60 detik, simpan key selama 24 jam. Jika klien mungkin mencoba ulang besok karena batch job, simpan key selama seminggu.
Jadikan permintaan pertama Anda idempotent hari ini
Tambahkan header Idempotency-Key ke setiap mutating endpoint. Mulai dengan cache 24 jam. Gunakan UUID4. Jadikan penyimpanannya atomic dengan business transaction Anda. Saat pertama kali container mati di tengah permintaan dan klien mencoba ulang, Anda akan senang sudah melakukannya. Alternatifnya adalah menjelaskan ke tim finance Anda mengapa satu pelanggan punya tujuh belas charge yang identik.