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]
    end

Kenapa 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 RetryPenyebabDampak Tanpa Idempotency
Network timeoutRequest sampai, response tidakClient retry → operasi dieksekusi 2x
Mobile app retryKoneksi putus sebelum responseUser tap sekali, transaksi 2x
Load balancer retryBackend lambat meresponsRequest di-forward ulang ke instance lain
Message queue redeliveryConsumer crash sebelum ackEvent diproses 2x oleh consumer berikutnya
Scheduled job retryJob gagal di tengah jalanProses 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 disadari

Idempotency 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 MethodIdempotentSafeKeterangan
GET✅ Ya✅ YaHanya membaca, tidak mengubah state
HEAD✅ Ya✅ YaSeperti GET, hanya header
PUT✅ Ya❌ TidakMengganti resource secara penuh
DELETE✅ Ya❌ TidakDELETE kedua mengembalikan 404, tapi state sama
POST❌ Tidak❌ TidakBiasanya membuat resource baru
PATCH❌ Biasanya❌ TidakTergantung implementasi
Status idempotent tidak ditentukan oleh HTTP method semata, tapi oleh implementasinya. PUT /users/123 yang memanggil UPDATE users SET login_count = login_count + 1 tetap 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]
AspekDeduplicationIdempotency
FokusMencegah request ganda masuk ke sistemMembuat request ganda aman diproses
PendekatanFilter di layer paling awalHandle di layer bisnis
JaminanRequest tidak diproses lebih dari sekaliHasil tetap konsisten meski diproses ulang
ContohTolak request dengan ID yang samaKembalikan 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 charge

Client 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:

AspekDatabaseRedis
KonsistensiSangat kuat (ACID)Eventual (bisa hilang saat restart)
ThroughputTerbatas oleh I/O diskSangat tinggi (in-memory)
PersistencePermanenPerlu konfigurasi AOF/RDB
Cocok untukTransaksi keuangan, data kritisAPI rate-limited, operasi idempoten ringan
TTL managementManual cleanupNative 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 --> A

Idempotency dan Database Transaction #

Idempotency dan transaction adalah dua konsep berbeda yang saling melengkapi, bukan menggantikan.

AspekDatabase TransactionIdempotency
JaminanAtomicity — semua atau tidak sama sekaliConsistency across retries
MekanismeRollback jika gagalSkip atau kembalikan hasil lama
ScopeSatu operasi databaseLintas request / event
Contoh kegagalanError di tengah INSERTNetwork 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 dilakukan

Kesalahan 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 NOTHING untuk atomic idempotency check yang aman dari race condition.
  • Redis untuk throughput tinggiSET NX bersifat atomic dan sangat cepat, cocok untuk API dengan volume besar.
  • Consumer queue harus idempotent — simpan event_id yang 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.

← Sebelumnya: Clean Code   Berikutnya: Race Condition →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact