Idempotency #

Di sistem yang berjalan di atas jaringan yang tidak sempurna, satu request bisa tiba dua kali. Client bisa retry karena timeout. Load balancer bisa meneruskan request yang sama ke dua instance berbeda. Message queue bisa mengirim ulang event yang consumer-nya crash di tengah eksekusi. Pertanyaannya bukan apakah hal ini akan terjadi — tapi kapan. Idempotency adalah properti yang membuat sistem kamu aman ketika “kapan” itu tiba: operasi yang dijalankan satu kali dan sepuluh kali menghasilkan efek yang persis sama. Panduan ini membahas idempotency dari konsep dasarnya, cara kerjanya di HTTP dan event-driven architecture, teknik implementasi konkret, hingga kesalahan umum yang sering dilakukan engineer.

Apa Itu Idempotency? #

Idempotency adalah sifat suatu operasi yang jika dijalankan berkali-kali dengan input yang sama, akan menghasilkan efek akhir yang identik dengan dijalankan satu kali saja. Efek samping tidak bertambah, state tidak berubah lebih jauh, hasil tidak berduplikasi.

Secara formal, sebuah fungsi disebut idempotent jika berlaku:

f(x) = f(f(x)) = f(f(f(x))) = ...

Artinya, menerapkan fungsi berulang kali tidak mengubah hasil. Dalam konteks sistem terdistribusi, “fungsi” ini bisa berupa HTTP endpoint, handler message queue, atau operasi database.

Contoh paling intuitif: menetapkan nilai adalah idempotent, menambahkan nilai tidak.

// BENAR — idempotent: dijalankan 10x hasilnya tetap sama
SET user.status = "active"

// ANTI-PATTERN — tidak idempotent: dijalankan 10x efeknya berlipat 10x
INCREMENT user.balance BY 100

Ini bukan hanya soal kode yang bersih — ini soal apakah sistem kamu aman saat diulang, dan di dunia nyata, pengulangan adalah hal yang pasti terjadi.


Idempotency dalam HTTP Method #

HTTP sudah merancang method-nya dengan mempertimbangkan idempotency. Penting untuk memahami klasifikasi ini karena menjadi fondasi desain API yang benar.

HTTP MethodIdempotentPenjelasan
GET✓ YaHanya membaca, tidak mengubah state
PUT✓ YaMengganti resource secara penuh — panggil 10x, hasilnya sama
DELETE✓ YaResource terhapus setelah pertama kali; panggilan berikutnya tidak mengubah state lebih jauh
HEAD✓ YaSama seperti GET tapi tanpa body
POST✗ TidakBiasanya membuat resource baru setiap kali dipanggil
PATCH✗ (umumnya)Update parsial; tergantung implementasi
Status idempotent bukan ditentukan oleh HTTP method semata, melainkan oleh implementasinya. Kamu bisa membuat POST idempotent dengan Idempotency Key. Kamu juga bisa membuat PUT tidak idempotent jika implementasinya keliru. Method hanya menyatakan niat — implementasi yang menentukan kenyataan.

Contoh perbedaan konkret antara POST dan PUT untuk kasus yang mirip:

# ANTI-PATTERN — POST ini tidak idempotent
# Dipanggil 3x = 3 record baru di database
POST /api/payments
{
  "user_id": 42,
  "amount": 150000
}

# BENAR — PUT ini idempotent
# Dipanggil 3x = state akhir selalu sama
PUT /api/users/42/subscription
{
  "plan": "premium",
  "expires_at": "2027-01-01"
}

Mengapa Idempotency Itu Kritis #

Ada dua realita di sistem modern yang membuat idempotency bukan sekadar nice-to-have.

Realita pertama: jaringan tidak dapat dipercaya. Timeout bisa terjadi setelah server memproses request tapi sebelum response sampai ke client. Akibatnya, dari sudut pandang client, request “gagal” — padahal server sudah mengeksekusinya. Client yang taat retry logic akan mengirim ulang, dan jika operasinya tidak idempotent, kamu punya masalah: double charge, double order, double notification.

Realita kedua: retry bukan opsi, tapi kewajiban. Di distributed system, retry adalah mekanisme ketahanan standar. Load balancer retry. Message broker retry. Kubernetes restart pod yang crash. Circuit breaker retry setelah recovery. Setiap lapisan infrastruktur ini bisa memicu eksekusi ulang — dan sistem kamu harus siap menghadapinya.

Skenario tanpa idempotency:

User klik "Bayar"
    │
    ▼
Client kirim POST /payments
    │
    ▼
Server proses pembayaran ✓
    │
    ▼
Koneksi timeout sebelum response sampai
    │
    ▼
Client retry → POST /payments lagi
    │
    ▼
Server proses pembayaran kedua kali ✗ — double charge
Skenario dengan idempotency:

User klik "Bayar"
    │
    ▼
Client kirim POST /payments (Idempotency-Key: abc-123)
    │
    ▼
Server proses pembayaran ✓ — simpan key abc-123
    │
    ▼
Koneksi timeout
    │
    ▼
Client retry → POST /payments (Idempotency-Key: abc-123)
    │
    ▼
Server: key abc-123 sudah ada → return response lama ✓

Idempotency Key — Teknik Paling Umum #

Idempotency Key adalah teknik di mana client menyertakan identifier unik bersama setiap request. Server menyimpan identifier ini beserta hasilnya — jika request dengan identifier yang sama datang lagi, server mengembalikan hasil yang tersimpan tanpa mengeksekusi ulang.

Cara Kerja #

POST /api/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "user_id": 42,
  "amount": 150000,
  "method": "transfer"
}

Alur di sisi server:

Terima request dengan Idempotency-Key: X
    │
    ├─ Key X belum ada di storage?
    │       │
    │       ▼
    │   Eksekusi operasi
    │   Simpan (key X → hasil)
    │   Return hasil baru
    │
    └─ Key X sudah ada di storage?
            │
            ▼
        Return hasil lama (tanpa eksekusi ulang)

Implementasi: Database-Based #

Pendekatan paling sederhana: simpan idempotency key di tabel khusus dengan unique constraint.

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    NOT NULL
);
// BENAR: cek dulu sebelum eksekusi
func (s *PaymentService) CreatePayment(ctx context.Context, req PaymentRequest, idempotencyKey string) (*PaymentResponse, error) {
    // Cek apakah key sudah pernah diproses
    existing, err := s.repo.FindIdempotencyKey(ctx, idempotencyKey)
    if err == nil && existing != nil {
        // ✓ Key sudah ada — kembalikan hasil lama
        return existing.Response, nil
    }

    // Key belum ada — proses dan simpan hasilnya
    result, err := s.processPayment(ctx, req)
    if err != nil {
        return nil, err
    }

    // Simpan key + hasil untuk request berikutnya
    s.repo.SaveIdempotencyKey(ctx, idempotencyKey, result, time.Now().Add(24*time.Hour))
    return result, nil
}

Implementasi: Cache-Based (Redis) #

Untuk throughput tinggi, Redis lebih efisien karena operasi SET NX (Set if Not Exists) bersifat atomic.

// BENAR: gunakan SET NX untuk atomic check-and-set
func (s *PaymentService) CreatePayment(ctx context.Context, req PaymentRequest, idempotencyKey string) (*PaymentResponse, error) {
    cacheKey := "idempotency:" + idempotencyKey

    // Coba simpan placeholder — hanya berhasil jika key belum ada
    set, err := s.redis.SetNX(ctx, cacheKey, "processing", 24*time.Hour).Result()
    if err != nil {
        return nil, err
    }

    if !set {
        // ✓ Key sudah ada — ambil hasil yang tersimpan
        cached, _ := s.redis.Get(ctx, cacheKey).Bytes()
        var response PaymentResponse
        json.Unmarshal(cached, &response)
        return &response, nil
    }

    // Key baru — proses dan update cache dengan hasil sebenarnya
    result, err := s.processPayment(ctx, req)
    if err != nil {
        s.redis.Del(ctx, cacheKey)
        return nil, err
    }

    resultJSON, _ := json.Marshal(result)
    s.redis.Set(ctx, cacheKey, resultJSON, 24*time.Hour)
    return result, nil
}

Idempotency di Message Queue #

Message queue adalah area di mana idempotency paling sering diabaikan — dan paling sering menimbulkan masalah. Hampir semua message broker modern beroperasi dengan semantik at-least-once delivery: pesan dijamin sampai, tapi bisa sampai lebih dari sekali.

Producer → Kafka/RabbitMQ → Consumer
                                │
                                ├─ Consumer proses pesan ✓
                                ├─ Consumer crash sebelum commit offset ✗
                                └─ Broker kirim ulang pesan yang sama

Consumer yang tidak idempotent akan memproses pesan yang sama dua kali — dan efeknya bergantung pada operasi apa yang dilakukan.

// ANTI-PATTERN: consumer tidak idempotent — tidak ada pengecekan duplikasi
func (c *OrderConsumer) HandleOrderCreated(msg OrderCreatedEvent) error {
    // Jika pesan ini dikirim ulang, order akan dibuat dua kali
    return c.orderRepo.Create(Order{
        UserID: msg.UserID,
        Items:  msg.Items,
        Total:  msg.Total,
    })
}

// BENAR: consumer idempotent — cek event ID sebelum proses
func (c *OrderConsumer) HandleOrderCreated(msg OrderCreatedEvent) error {
    // Gunakan event ID sebagai idempotency key
    processed, err := c.eventRepo.IsProcessed(msg.EventID)
    if err != nil {
        return err
    }
    if processed {
        // ✓ Event sudah diproses sebelumnya — skip tanpa error
        return nil
    }

    // Proses dalam satu transaction: buat order + tandai event sebagai processed
    return c.db.Transaction(func(tx *gorm.DB) error {
        if err := tx.Create(&Order{UserID: msg.UserID, Items: msg.Items}).Error; err != nil {
            return err
        }
        return tx.Create(&ProcessedEvent{EventID: msg.EventID}).Error
    })
}
Beberapa message broker seperti AWS SQS (FIFO queue) dan Apache Kafka (dengan exactly-once semantics) menyediakan deduplication bawaan. Tapi ini tidak menggantikan kebutuhan consumer yang idempotent — deduplication di level broker hanya bekerja dalam jendela waktu tertentu dan tidak mencakup semua skenario retry.

Idempotency vs Transaction vs Deduplication #

Tiga konsep ini sering dicampuradukkan padahal punya peran yang berbeda dan saling melengkapi.

Konsep          Fokus                           Scope
─────────────   ──────────────────────────────  ────────────────────
Transaction     Atomicity — semua atau tidak     Database
                sama sekali; rollback jika gagal

Deduplication   Mencegah request ganda masuk     Network / Queue layer
                ke sistem

Idempotency     Membuat eksekusi ulang tetap     Seluruh sistem
                aman — state akhir konsisten

Idempotency sering membutuhkan transaction untuk implementasinya (seperti contoh consumer di atas), dan melengkapi deduplication dengan menjadi lapisan keamanan terakhir ketika duplikasi tetap lolos.


Anti-Pattern yang Harus Dihindari #

// ✗ Mengandalkan client untuk tidak retry
// Asumsi ini selalu salah di sistem nyata
func createPayment(w http.ResponseWriter, r *http.Request) {
    // Tidak ada pengecekan idempotency sama sekali
    payment := processPayment(r.Body)
    json.NewEncoder(w).Encode(payment)
}

// ✓ Selalu asumsikan request bisa datang lebih dari sekali
func createPayment(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        http.Error(w, "Idempotency-Key header required", 400)
        return
    }
    // ... cek dan proses dengan idempotency
}

// ✗ Menggunakan timestamp sebagai idempotency key
// Timestamp tidak unik — dua request dalam millisecond yang sama akan bentrok
idempotencyKey := strconv.FormatInt(time.Now().UnixMilli(), 10)

// ✓ Gunakan UUID v4 yang dijamin unik
idempotencyKey := uuid.New().String()

// ✗ Idempotency key tanpa TTL
// Menyimpan selamanya = storage leak
db.Exec("INSERT INTO idempotency_keys (key, response) VALUES (?, ?)", key, response)

// ✓ Selalu set TTL yang sesuai dengan retry window
db.Exec(`
    INSERT INTO idempotency_keys (key, response, expires_at)
    VALUES (?, ?, NOW() + INTERVAL '24 hours')
`, key, response)

Kapan Idempotency Tidak Diperlukan #

Tidak semua operasi membutuhkan perlakuan idempotency eksplisit. Beberapa panduan:

Idempotency eksplisit WAJIB jika:
  ✓ Operasi melibatkan uang atau transaksi finansial
  ✓ Operasi membuat resource baru (POST)
  ✓ Consumer message queue yang melakukan write
  ✓ Operasi mengirim notifikasi (email, push notification, SMS)
  ✓ Operasi yang melakukan increment/decrement nilai

Idempotency eksplisit bisa diabaikan jika:
  ✗ Operasi read-only (GET, query SELECT)
  ✗ Operasi yang secara alami sudah idempotent (SET, DELETE)
  ✗ Internal service call yang tidak punya side effect eksternal
  ✗ Operasi dalam satu database transaction yang di-rollback saat gagal

Ringkasan #

  • Idempotency adalah sifat operasi yang aman dieksekusi berkali-kali — hasil akhirnya selalu sama.
  • Retry adalah keniscayaan di distributed system; desain sistem kamu harus mengasumsikan setiap request bisa datang lebih dari sekali.
  • HTTP method memberi sinyal niat idempotency, tapi implementasi yang menentukan — POST bisa dibuat idempotent, PUT bisa dibuat tidak idempotent.
  • Idempotency Key adalah teknik standar: client kirim UUID unik per operasi, server simpan hasilnya dan return hasil lama jika key sama datang lagi.
  • Database-based (unique constraint) cocok untuk simplicity; Redis-based (SET NX) cocok untuk high throughput.
  • Consumer message queue harus selalu idempotent — broker dengan at-least-once delivery akan selalu berpotensi mengirim ulang pesan.
  • Idempotency melengkapi transaction, bukan menggantikannya — keduanya sering dipakai bersama untuk konsistensi yang lengkap.
  • TTL pada idempotency key wajib ada — menyimpan selamanya adalah storage leak yang terselubung.
  • Untuk operasi finansial, notifikasi, dan pembuatan resource baru: idempotency bukan opsional.

← Sebelumnya: Clean Code   Berikutnya: Race Condition →

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