Idempotency #
Di sistem modern — terutama backend, microservices, dan distributed systems — jaringan tidak pernah benar-benar bisa dipercaya. Request gagal di tengah jalan, client melakukan retry, load balancer mencoba lagi, message queue mengirim ulang event yang sama. Dalam kondisi ini, satu pertanyaan jadi krusial: apa yang terjadi jika operasi yang sama dieksekusi lebih dari sekali? Jika jawabannya “bisa double charge”, “bisa double order”, atau “bisa corrupt data” — sistem kamu belum idempotent. Artikel ini membahas idempotency dari akar konsepnya, mengapa retry adalah keniscayaan bukan exception, cara mengimplementasikan idempotency key, dan pola-pola konkret untuk database, Redis, dan message queue.
Apa Itu Idempotency? #
Idempotency adalah sifat sebuah operasi yang jika dijalankan berkali-kali dengan input yang sama, akan menghasilkan efek yang sama seperti dijalankan hanya satu kali. Bukan berarti operasinya tidak boleh diulang — tapi bahwa pengulangan tidak menambah efek samping baru.
Dalam matematika, ini ditulis sebagai:
f(x) = f(f(x))
Dalam konteks software: request yang sama dikirim 1 kali atau 100 kali, sistem tetap berada pada state akhir yang sama.
Ilustrasi paling mudah: tombol lift. Menekan tombol lantai 5 satu kali atau sepuluh kali tidak membuat lift pergi ke lantai 5 sebanyak sepuluh kali. Efeknya sama — lift menuju lantai 5. Inilah idempotency.
flowchart LR
subgraph NonIdempotent["❌ Tidak Idempotent"]
A1["Request ke-1\nPOST /balance/add 100"] --> B1[Saldo +100]
A2["Request ke-2\nPOST /balance/add 100"] --> B2[Saldo +200]
A3["Request ke-3\nPOST /balance/add 100"] --> B3[Saldo +300]
end
subgraph Idempotent["✅ Idempotent"]
C1["Request ke-1\nPUT /balance/set 100"] --> D1[Saldo = 100]
C2["Request ke-2\nPUT /balance/set 100"] --> D2[Saldo = 100]
C3["Request ke-3\nPUT /balance/set 100"] --> D3[Saldo = 100]
endKenapa Idempotency Penting? #
Alasan utama: dunia nyata tidak ideal. Engineer sering menulis kode seolah setiap request akan sampai tepat sekali, diproses sekali, dan berhasil. Kenyataannya sangat berbeda.
Berikut skenario yang terjadi setiap hari di sistem produksi:
| Sumber Retry | Penyebab | Dampak Tanpa Idempotency |
|---|---|---|
| Network timeout | Request sampai, response tidak | Client retry → operasi dieksekusi 2x |
| Mobile app retry | Koneksi putus sebelum response | User tap sekali, transaksi 2x |
| Load balancer retry | Backend lambat merespons | Request di-forward ulang ke instance lain |
| Message queue redelivery | Consumer crash sebelum ack | Event diproses 2x oleh consumer berikutnya |
| Scheduled job retry | Job gagal di tengah jalan | Proses batch diulang dari awal |
Di distributed system, retry bukan edge case — ini perilaku default. Semua library HTTP, semua message broker, semua orchestrator punya mekanisme retry bawaan. Artinya sistem kamu akan menerima request duplikat. Pertanyaannya hanya apakah sistem siap menghadapinya.
sequenceDiagram
participant Client
participant Network
participant Server
participant DB
Client->>Network: POST /payments (Rp 500.000)
Network->>Server: Request diterima
Server->>DB: INSERT payment
DB-->>Server: OK
Note over Network: Timeout! Response tidak sampai
Server--xClient: Response hilang
Client->>Network: Retry POST /payments (Rp 500.000)
Network->>Server: Request diterima lagi
Server->>DB: INSERT payment LAGI
DB-->>Server: OK
Server-->>Client: 200 OK
Note over Client,DB: User membayar 2x — tanpa disadariIdempotency dalam HTTP Method #
HTTP sudah mengklasifikasikan method berdasarkan sifat idempotency-nya sejak RFC awal. Tapi ini sering disalahpahami sebagai jaminan teknis, padahal ini adalah kontrak semantik — tanggung jawab implementasi tetap ada di tangan engineer.
| HTTP Method | Idempotent | Safe | Keterangan |
|---|---|---|---|
| GET | ✅ Ya | ✅ Ya | Hanya membaca, tidak mengubah state |
| HEAD | ✅ Ya | ✅ Ya | Seperti GET, hanya header |
| PUT | ✅ Ya | ❌ Tidak | Mengganti resource secara penuh |
| DELETE | ✅ Ya | ❌ Tidak | DELETE kedua mengembalikan 404, tapi state sama |
| POST | ❌ Tidak | ❌ Tidak | Biasanya membuat resource baru |
| PATCH | ❌ Biasanya | ❌ Tidak | Tergantung implementasi |
Status idempotent tidak ditentukan oleh HTTP method semata, tapi oleh implementasinya.PUT /users/123yang memanggilUPDATE users SET login_count = login_count + 1tetap tidak idempotent meskipun menggunakan PUT. Idempotency ada di logika bisnis, bukan di method.
Contoh yang sering membingungkan: DELETE /orders/456 dikirim dua kali. Request pertama menghapus order dan mengembalikan 200 OK. Request kedua mendapat 404 Not Found. Apakah ini idempotent? Ya — karena state akhir sistem sama: order 456 tidak ada.
Idempotency vs Deduplication #
Dua konsep ini sering disamakan padahal berbeda tujuan:
flowchart TD
A[Request Duplikat Datang] --> B{Deduplication?}
B -- Ya --> C["Blokir request\nsebelum diproses"]
B -- Tidak --> D{Idempotency?}
D -- Ya --> E["Proses request\ntapi hasilkan efek sama"]
D -- Tidak --> F["❌ Double Effect\nDouble charge, double order"]
C --> G[Response: request sudah ada]
E --> H[Response: hasil sama seperti sebelumnya]| Aspek | Deduplication | Idempotency |
|---|---|---|
| Fokus | Mencegah request ganda masuk ke sistem | Membuat request ganda aman diproses |
| Pendekatan | Filter di layer paling awal | Handle di layer bisnis |
| Jaminan | Request tidak diproses lebih dari sekali | Hasil tetap konsisten meski diproses ulang |
| Contoh | Tolak request dengan ID yang sama | Kembalikan hasil sebelumnya untuk ID yang sama |
Idealnya sistem menggunakan keduanya: deduplication sebagai optimasi performa, idempotency sebagai jaminan keselamatan.
Idempotency Key #
Teknik paling umum untuk mengimplementasikan idempotency pada operasi non-idempotent (seperti POST) adalah Idempotency Key — sebuah identifier unik yang dikirim client bersama setiap request.
Cara Kerja #
sequenceDiagram
participant Client
participant Server
participant Store as Key Store\n(DB / Redis)
Client->>Server: POST /payments\nIdempotency-Key: uuid-abc-123
Server->>Store: Cek key uuid-abc-123
Store-->>Server: Tidak ditemukan
Server->>Server: Proses pembayaran
Server->>Store: Simpan key + result
Server-->>Client: 200 OK {payment_id: 789}
Note over Client: Timeout, retry!
Client->>Server: POST /payments\nIdempotency-Key: uuid-abc-123
Server->>Store: Cek key uuid-abc-123
Store-->>Server: Ditemukan! Result: {payment_id: 789}
Server-->>Client: 200 OK {payment_id: 789}
Note over Client,Store: Tidak ada double chargeClient bertanggung jawab membuat key yang unik per operasi bisnis — biasanya UUID v4. Server bertanggung jawab menyimpan key dan response-nya, serta mengembalikan response lama jika key yang sama datang lagi.
Format Header #
POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
"amount": 500000,
"recipient_id": "user-123"
}
Aturan Key yang Baik #
✓ UUID v4 — acak, tidak predictable
✓ Dibuat oleh client — server tidak boleh generate key untuk client
✓ Satu key per operasi bisnis — bukan per HTTP call
✗ Timestamp saja — bisa collision
✗ Auto-increment — predictable, bisa ditebak
✗ Kombinasi field bisnis tanpa UUID — terlalu mudah collision
Implementasi Idempotency #
Database-Based #
Pendekatan paling sederhana dan paling terjamin konsistensinya: simpan idempotency key di tabel database dengan unique constraint.
-- Buat tabel idempotency store
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
response JSONB NOT NULL,
status_code INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP
);
-- Index untuk cleanup expired keys
CREATE INDEX idx_expires_at ON idempotency_keys(expires_at);
// ANTI-PATTERN: tidak ada idempotency check — double charge bisa terjadi
func processPayment(req PaymentRequest) (*Payment, error) {
payment := Payment{
Amount: req.Amount,
RecipientID: req.RecipientID,
}
return db.CreatePayment(payment)
}
// BENAR: idempotency check sebelum proses, simpan result setelah sukses
func processPayment(req PaymentRequest, idempotencyKey string) (*Payment, error) {
// Cek apakah key sudah pernah diproses
existing, err := db.FindIdempotencyKey(idempotencyKey)
if err == nil && existing != nil {
// Key sudah ada — kembalikan hasil sebelumnya tanpa proses ulang
var payment Payment
json.Unmarshal(existing.Response, &payment)
return &payment, nil
}
// Proses pembayaran
payment := Payment{
Amount: req.Amount,
RecipientID: req.RecipientID,
}
created, err := db.CreatePayment(payment)
if err != nil {
return nil, err
}
// Simpan key + result untuk request berikutnya
responseBytes, _ := json.Marshal(created)
db.SaveIdempotencyKey(IdempotencyKey{
Key: idempotencyKey,
Response: responseBytes,
ExpiresAt: time.Now().Add(24 * time.Hour),
})
return created, nil
}
Untuk operasi yang melibatkan uang atau data kritis, gunakan database transaction yang membungkus operasi bisnis dan penyimpanan idempotency key sekaligus. Ini mencegah race condition di mana dua request dengan key sama tiba bersamaan.
Cache-Based (Redis) #
Untuk operasi dengan throughput tinggi, Redis lebih efisien dari database karena operasi SET NX (set if not exists) bersifat atomic.
// ANTI-PATTERN: set biasa — bisa di-overwrite oleh request concurrent
redisClient.Set(ctx, key, response, ttl)
// BENAR: SET NX — atomic, hanya set jika key belum ada
func processWithRedisIdempotency(key string, ttl time.Duration, fn func() (interface{}, error)) (interface{}, error) {
// Coba ambil hasil yang sudah ada
cached, err := redisClient.Get(ctx, key).Result()
if err == nil {
// Key ditemukan, kembalikan cached result
var result interface{}
json.Unmarshal([]byte(cached), &result)
return result, nil
}
// Proses operasi
result, err := fn()
if err != nil {
return nil, err
}
// Simpan dengan SET NX agar concurrent request tidak overwrite
resultBytes, _ := json.Marshal(result)
redisClient.SetNX(ctx, key, string(resultBytes), ttl)
return result, nil
}
Perbandingan pendekatan database vs Redis:
| Aspek | Database | Redis |
|---|---|---|
| Konsistensi | Sangat kuat (ACID) | Eventual (bisa hilang saat restart) |
| Throughput | Terbatas oleh I/O disk | Sangat tinggi (in-memory) |
| Persistence | Permanen | Perlu konfigurasi AOF/RDB |
| Cocok untuk | Transaksi keuangan, data kritis | API rate-limited, operasi idempoten ringan |
| TTL management | Manual cleanup | Native TTL |
Message Queue — Consumer Idempotent #
Di event-driven architecture, consumer harus idempotent by design karena message broker menjamin at-least-once delivery, bukan exactly-once.
// ANTI-PATTERN: consumer langsung proses tanpa cek duplikat
func handleOrderEvent(event OrderEvent) error {
return db.CreateShipment(Shipment{
OrderID: event.OrderID,
Address: event.ShippingAddress,
})
// Jika event dikirim ulang → shipment duplikat!
}
// BENAR: cek event_id sebelum proses, skip jika sudah diproses
func handleOrderEvent(event OrderEvent) error {
// Cek apakah event ini sudah pernah diproses
alreadyProcessed, err := db.IsEventProcessed(event.EventID)
if err != nil {
return err
}
if alreadyProcessed {
// Bukan error — ini expected behavior, skip saja
log.Info("event already processed, skipping", "event_id", event.EventID)
return nil
}
// Proses dalam transaction: buat shipment + tandai event sebagai processed
return db.Transaction(func(tx *DB) error {
if err := tx.CreateShipment(Shipment{
OrderID: event.OrderID,
Address: event.ShippingAddress,
}); err != nil {
return err
}
return tx.MarkEventProcessed(event.EventID)
})
}
flowchart TD
A[Event Masuk dari Queue] --> B[Baca event_id]
B --> C{"event_id sudah\ndi processed_events?"}
C -- Ya --> D[Skip — log dan ack]
C -- Tidak --> E[Proses event dalam DB transaction]
E --> F[Insert hasil operasi]
F --> G[Insert event_id ke processed_events]
G --> H{"Transaction\nberhasil?"}
H -- Ya --> I[Ack ke queue]
H -- Tidak --> J[Rollback — event akan di-redeliver]
J --> AIdempotency dan Database Transaction #
Idempotency dan transaction adalah dua konsep berbeda yang saling melengkapi, bukan menggantikan.
| Aspek | Database Transaction | Idempotency |
|---|---|---|
| Jaminan | Atomicity — semua atau tidak sama sekali | Consistency across retries |
| Mekanisme | Rollback jika gagal | Skip atau kembalikan hasil lama |
| Scope | Satu operasi database | Lintas request / event |
| Contoh kegagalan | Error di tengah INSERT | Network timeout setelah commit |
Kasus kritis: transaksi database sudah commit, tapi response belum sampai ke client karena network drop. Transaction tidak bisa membantu di sini — idempotency key yang menyelamatkan.
sequenceDiagram
participant Client
participant Server
participant DB
Client->>Server: POST /transfer (key=X)
Server->>DB: BEGIN TRANSACTION
Server->>DB: Debit account A
Server->>DB: Credit account B
Server->>DB: COMMIT ✓
Note over Server,Client: Network drop!
Server--xClient: Response hilang
Client->>Server: Retry POST /transfer (key=X)
Server->>DB: Cek idempotency_keys WHERE key=X
DB-->>Server: Found! Transaksi sudah berhasil
Server-->>Client: 200 OK — transfer sudah dilakukanKesalahan Umum dalam Implementasi #
// ✗ Kesalahan 1: Mengandalkan client untuk tidak retry
// Jangan pernah asumsi ini — semua klien melakukan retry
// ✗ Kesalahan 2: Idempotency key tanpa TTL — storage membengkak
db.SaveIdempotencyKey(key, response) // tanpa expiry
// ✓ Selalu set TTL yang masuk akal (24 jam untuk kebanyakan kasus)
db.SaveIdempotencyKey(key, response, expiresAt: time.Now().Add(24*time.Hour))
// ✗ Kesalahan 3: Consumer queue tidak idempotent
func consume(event Event) {
db.Insert(...) // langsung insert tanpa cek duplikat
}
// ✓ Selalu cek event_id sebelum proses
func consume(event Event) {
if db.IsProcessed(event.ID) { return nil }
// proses...
}
// ✗ Kesalahan 4: Race condition pada idempotency check
existing := db.Find(key) // cek
if existing == nil { // gap di sini! request lain bisa masuk
db.Save(key, result) // simpan — mungkin duplikat
}
// ✓ Gunakan atomic operation: INSERT ON CONFLICT atau SET NX
db.Exec("INSERT INTO idempotency_keys ... ON CONFLICT (key) DO NOTHING")
// ✗ Kesalahan 5: Idempotency key di-generate server, bukan client
func createPayment(req Request) {
key := uuid.New() // server generate — retry client dapat key berbeda!
// ...
}
// ✓ Key harus dari client, server hanya menerima dan memvalidasi
func createPayment(req Request, idempotencyKey string) {
// ...
}
Checklist Implementasi Idempotency #
DESAIN:
□ Semua write-operation yang bisa di-retry sudah diidentifikasi
□ Strategi penyimpanan key sudah ditentukan (DB vs Redis)
□ TTL untuk idempotency key sudah ditetapkan
□ Format key sudah didokumentasikan di API spec
IMPLEMENTASI:
□ Idempotency check dilakukan sebelum operasi bisnis
□ Penyimpanan key + result dalam satu atomic operation
□ Race condition ditangani dengan INSERT ON CONFLICT atau SET NX
□ Consumer message queue melakukan cek event_id
ERROR HANDLING:
□ Key expired dikembalikan sebagai error yang jelas
□ Key dengan payload berbeda ditolak (key conflict)
□ Logging ketika request duplikat terdeteksi
TESTING:
□ Test skenario: request pertama berhasil
□ Test skenario: request duplikat dengan key yang sama
□ Test skenario: dua request concurrent dengan key yang sama
□ Test skenario: key expired, client retry dengan key baru
Ringkasan #
- Idempotency adalah jaminan keamanan retry — operasi yang sama boleh dieksekusi berkali-kali tanpa efek samping tambahan.
- Retry adalah keniscayaan — network, load balancer, dan message queue semua melakukan retry; sistem harus siap menerimanya.
- HTTP method bukan jaminan — PUT dan DELETE idempotent secara semantik, tapi implementasi tetap tanggung jawab engineer.
- Idempotency Key — teknik paling umum: client kirim UUID unik per operasi, server simpan key + hasil, request duplikat mendapat hasil lama.
- Database untuk data kritis — gunakan
INSERT ON CONFLICT DO NOTHINGuntuk atomic idempotency check yang aman dari race condition.- Redis untuk throughput tinggi —
SET NXbersifat atomic dan sangat cepat, cocok untuk API dengan volume besar.- Consumer queue harus idempotent — simpan
event_idyang sudah diproses dan skip jika ditemukan lagi.- Idempotency melengkapi transaction — transaction menjamin atomicity dalam satu operasi, idempotency menjamin keamanan lintas request.
- TTL wajib — selalu set expiry pada idempotency key agar storage tidak membengkak tanpa batas.