Idempotency #

Setiap sistem yang terhubung ke internet akan mengalami kegagalan jaringan, timeout, dan retry. Ini bukan skenario tepi — ini adalah kondisi normal. Yang membedakan sistem yang robust dari sistem yang rapuh adalah bagaimana mereka merespons ketika request yang sama dikirim lebih dari sekali: apakah sistem memproses dua kali dan menghasilkan dua charge ke kartu kredit user, atau apakah sistem mengenali bahwa ini request yang sama dan mengembalikan hasil yang sudah ada tanpa side effect tambahan? Idempotency adalah properti yang menjawab pertanyaan ini. Artikel ini membahas mengapa idempotency penting, bagaimana mengimplementasikannya dengan Idempotency-Key pattern, atomic claim dengan database, request hashing untuk keamanan, dan anti-pattern yang harus dihindari.

Masalah yang Diselesaikan Idempotency #

Untuk memahami mengapa idempotency penting, bayangkan skenario checkout di aplikasi e-commerce:

sequenceDiagram
    participant U as User/Client
    participant S as Server
    participant PG as Payment Gateway
    participant DB as Database

    Note over U,DB: Tanpa Idempotency — Retry = Double Charge

    U->>S: POST /checkout { items, payment }
    S->>PG: Charge kartu kredit Rp 500.000
    PG-->>S: Success
    S->>DB: Insert order
    Note over S: Server crash / timeout sebelum kirim response!
    U--xU: Timeout — tidak dapat response

    U->>S: POST /checkout { items, payment } (RETRY)
    S->>PG: Charge kartu kredit Rp 500.000 LAGI
    PG-->>S: Success
    S->>DB: Insert order LAGI
    S-->>U: 200 OK

    Note over U,DB: User di-charge Rp 1.000.000 untuk satu pembelian!

    Note over U,DB: Dengan Idempotency — Retry Aman

    U->>S: POST /checkout { Idempotency-Key: key-abc }
    S->>PG: Charge Rp 500.000
    PG-->>S: Success
    S->>DB: Insert order + simpan key-abc + response
    Note over S: Server crash / timeout!
    U--xU: Timeout

    U->>S: POST /checkout { Idempotency-Key: key-abc } (RETRY)
    S->>DB: Cek key-abc → sudah ada, ambil cached response
    S-->>U: 200 OK (response yang sama, tidak ada charge kedua)

Skenario ini tidak hanya terjadi karena user yang dengan sengaja submit dua kali. Lebih sering terjadi karena:

Penyebab retry yang tidak terlihat:

1. Network timeout — mobile SDK retry otomatis setelah 3 detik tidak ada response
2. Server restart saat deployment — request yang in-flight dikirim ulang oleh load balancer
3. Client-side retry logic — JavaScript fetch dengan retry jika terjadi network error
4. Message queue at-least-once delivery — event dikirim lebih dari sekali
5. Webhook retry — payment gateway mengirim ulang webhook jika tidak ada response 200
6. Service mesh retry — Istio/Envoy retry request yang gagal secara otomatis

Definisi dan Hubungannya dengan HTTP Method #

Sebuah operasi dikatakan idempotent jika menjalankannya satu kali atau beberapa kali menghasilkan state akhir yang sama.

Operasi idempotent:
  DELETE /users/123 — dieksekusi 1x atau 5x, user 123 tetap tidak ada
  PUT /users/123 { name: "Budi" } — dieksekusi berkali-kali, nama tetap "Budi"
  GET /users — tidak mengubah state, selalu aman

Operasi tidak idempotent by nature:
  POST /payments { amount: 500000 } — dieksekusi 2x = 2 pembayaran
  POST /orders — dieksekusi 2x = 2 order
  PATCH /counter { increment: 1 } — dieksekusi 2x = counter bertambah 2
Hubungan dengan HTTP method (spesifikasi):

Method      Idempotent    Safe (tidak mengubah state)
GET         ✓             ✓
HEAD        ✓             ✓
PUT         ✓             ✗
DELETE      ✓             ✗
POST        ✗             ✗
PATCH       ✗ (umumnya)   ✗

PENTING: Idempotency menurut HTTP spec adalah tentang
         "apakah request yang sama menghasilkan efek yang sama"
         Tapi IMPLEMENTASI backend yang menentukan apakah
         ini benar-benar terjadi atau tidak.

DELETE yang tidak idempotent:
  DELETE /notifications/oldest → menghapus yang paling lama, bukan ID spesifik
  Dieksekusi 2x → menghapus 2 notifikasi berbeda
  Ini tidak idempotent meskipun menggunakan DELETE

POST yang dibuat idempotent:
  POST /payments dengan Idempotency-Key → dibuat idempotent melalui implementasi

Idempotency-Key Pattern #

Pattern yang paling umum dan direkomendasikan untuk membuat operasi non-idempotent menjadi idempotent adalah dengan Idempotency-Key header.

flowchart TD
    Start["Request masuk dengan\nIdempotency-Key: key-xyz"]

    CheckKey{"Cek key-xyz\ndi storage"}
    KeyFound["Key ditemukan"]
    KeyNotFound["Key tidak ditemukan"]

    CheckStatus{"Status key?"}
    Completed["Status: completed"]
    Processing["Status: processing"]
    ReturnCached["Return cached response\n(tidak proses ulang)"]
    ReturnConflict["Return 409 Conflict\n(masih diproses)"]

    HashCheck{"Request hash\ncocok dengan\nyang tersimpan?"}
    HashMismatch["Return 422\nPayload berbeda\nuntuk key yang sama"]

    ClaimKey["Atomic: Insert key\ndengan status 'processing'\n(UNIQUE constraint)"]
    ClaimSuccess{"Insert berhasil?\n(affected rows = 1)"}
    Duplicate["Return 409\nConcurrent duplicate"]

    Execute["Eksekusi business logic"]
    StoreResult["Simpan response +\nupdate status 'completed'"]
    ReturnResponse["Return response"]

    Start --> CheckKey
    CheckKey -->|"Ada"| KeyFound
    CheckKey -->|"Tidak ada"| ClaimKey

    KeyFound --> CheckStatus
    CheckStatus -->|"completed"| Completed
    CheckStatus -->|"processing"| Processing
    Completed --> HashCheck
    HashCheck -->|"Cocok"| ReturnCached
    HashCheck -->|"Tidak cocok"| HashMismatch
    Processing --> ReturnConflict

    ClaimKey --> ClaimSuccess
    ClaimSuccess -->|"Ya"| Execute
    ClaimSuccess -->|"Tidak (duplicate insert)"| Duplicate

    Execute --> StoreResult
    StoreResult --> ReturnResponse

    style ReturnCached fill:#27AE60,color:#fff
    style HashMismatch fill:#E74C3C,color:#fff
    style ReturnConflict fill:#E67E22,color:#fff
    style Duplicate fill:#E74C3C,color:#fff

Implementasi di Go #

// Tabel untuk menyimpan idempotency keys
// CREATE TABLE idempotency_keys (
//   key         VARCHAR(128) PRIMARY KEY,
//   request_hash VARCHAR(64) NOT NULL,
//   status      VARCHAR(16) NOT NULL DEFAULT 'processing',  -- processing | completed
//   response    JSONB,
//   status_code INT,
//   user_id     VARCHAR(64) NOT NULL,
//   endpoint    VARCHAR(256) NOT NULL,
//   expires_at  TIMESTAMP NOT NULL,
//   created_at  TIMESTAMP NOT NULL DEFAULT NOW()
// );

type IdempotencyKey struct {
    Key         string
    RequestHash string
    Status      string
    Response    []byte
    StatusCode  int
    UserID      string
    Endpoint    string
    ExpiresAt   time.Time
}

func (h *Handler) HandleWithIdempotency(w http.ResponseWriter, r *http.Request) {
    idempotencyKey := r.Header.Get("Idempotency-Key")
    if idempotencyKey == "" {
        http.Error(w, "Idempotency-Key header required", 400)
        return
    }

    // Hitung hash dari request body untuk validasi payload konsistensi
    body, _ := io.ReadAll(r.Body)
    r.Body = io.NopCloser(bytes.NewReader(body))  // reset body
    requestHash := sha256Hex(body)

    userID := getAuthenticatedUser(r).ID

    // Langkah 1: Cek apakah key sudah ada
    existing, err := h.repo.GetIdempotencyKey(r.Context(), idempotencyKey, userID)
    if err == nil && existing != nil {
        // Key sudah ada — validasi hash payload
        if existing.RequestHash != requestHash {
            // Payload berbeda untuk key yang sama — ini bug client
            http.Error(w, "Payload mismatch for existing idempotency key", 422)
            return
        }

        if existing.Status == "completed" {
            // Return cached response
            w.Header().Set("Idempotent-Replayed", "true")
            w.WriteHeader(existing.StatusCode)
            w.Write(existing.Response)
            return
        }

        // Status masih "processing" — request concurrent
        http.Error(w, "Request still being processed", 409)
        return
    }

    // Langkah 2: Atomic claim — insert key dengan UNIQUE constraint
    err = h.repo.ClaimIdempotencyKey(r.Context(), &IdempotencyKey{
        Key:         idempotencyKey,
        RequestHash: requestHash,
        Status:      "processing",
        UserID:      userID,
        Endpoint:    r.URL.Path,
        ExpiresAt:   time.Now().Add(24 * time.Hour),
    })
    if err != nil {
        // Insert gagal — concurrent request dengan key yang sama
        http.Error(w, "Concurrent request with same key", 409)
        return
    }

    // Langkah 3: Eksekusi business logic
    responseBody, statusCode, bizErr := h.executeBusinessLogic(r.Context(), body)
    if bizErr != nil {
        // Update status ke "failed" agar tidak stuck di "processing"
        h.repo.UpdateIdempotencyKey(r.Context(), idempotencyKey, "failed", nil, 0)
        http.Error(w, bizErr.Error(), 500)
        return
    }

    // Langkah 4: Simpan response dan update status ke "completed"
    h.repo.UpdateIdempotencyKey(r.Context(), idempotencyKey, "completed", responseBody, statusCode)

    // Langkah 5: Kirim response ke client
    w.WriteHeader(statusCode)
    w.Write(responseBody)
}

Request Hashing untuk Keamanan #

Request hashing adalah mekanisme untuk mendeteksi ketika client mengirim payload yang berbeda dengan idempotency key yang sama — yang bisa jadi bug di sisi client atau percobaan abuse.

Skenario yang perlu dicegah:

Request 1 (original):
  POST /payments
  Idempotency-Key: key-abc-123
  Body: { "amount": 500000, "recipient": "acc_budi" }

Request 2 (retry yang mencurigakan):
  POST /payments
  Idempotency-Key: key-abc-123  ← key yang sama
  Body: { "amount": 50000000, "recipient": "acc_penipu" }  ← payload berbeda!

Tanpa request hash check: server mungkin return cached response dari request 1
→ Client berpikir transfer Rp 50 juta berhasil (padahal hanya Rp 500 ribu)
→ Ini adalah security issue dan bug yang berbahaya

Dengan request hash check:
→ Server mendeteksi hash berbeda → return 422 Unprocessable Entity
→ Client harus handle error ini
// Cara menghitung request hash yang konsisten
func calculateRequestHash(body []byte) string {
    // Normalize JSON sebelum hash untuk menghindari false mismatch
    // karena perbedaan key ordering atau whitespace
    var normalized interface{}
    json.Unmarshal(body, &normalized)
    normalizedJSON, _ := json.Marshal(normalized)

    hash := sha256.Sum256(normalizedJSON)
    return hex.EncodeToString(hash[:])
}

// Field yang TIDAK boleh masuk dalam hash:
// - Timestamp yang berubah setiap request
// - Nonce atau random values
// - Field yang memang berbeda antar retry (bukan payload bisnis)

TTL dan Lifecycle Management #

Idempotency key tidak boleh disimpan selamanya — ini akan menyebabkan storage yang terus bertumbuh tanpa batas.

Panduan TTL berdasarkan use case:

Operasi finansial (payment, transfer):
  TTL: 24–48 jam
  Alasan: payment gateway biasanya melakukan retry dalam window ini

Order creation:
  TTL: 4–12 jam
  Alasan: user tidak akan retry checkout setelah beberapa jam

Operasi idempoten pendek (update data):
  TTL: 1–4 jam

Webhook processing:
  TTL: 72 jam
  Alasan: beberapa sistem retry webhook hingga 3 hari

Lifecycle key:
  processing  → completed (berhasil)
  processing  → failed (gagal, bisa di-retry)
  Expired key → dihapus via scheduled job

Cleanup strategy:
  Opsi 1: Cron job harian yang hapus key dengan expires_at < NOW()
  Opsi 2: TTL native di Redis (jika menggunakan Redis sebagai storage)
  Opsi 3: Partitioned table di PostgreSQL dengan DROP partition untuk data lama
Jangan lupa mengimplementasikan cleanup untuk idempotency keys yang sudah expired. Tanpa cleanup, tabel idempotency_keys akan bertumbuh linear seiring waktu dan bisa menjadi sumber masalah performa. Scheduled job yang berjalan setiap malam untuk menghapus expired keys adalah kebutuhan minimum.

Natural Idempotency dengan Database Constraint #

Selain pattern Idempotency-Key yang eksplisit, banyak operasi bisa dibuat idempotent secara alami menggunakan database constraint.

-- Natural idempotency untuk payment processing
-- Tidak ada dua pembayaran untuk order yang sama dengan payment method yang sama

CREATE TABLE payments (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id    UUID NOT NULL,
    method      VARCHAR(50) NOT NULL,
    amount      BIGINT NOT NULL,
    status      VARCHAR(20) NOT NULL,
    external_id VARCHAR(128),
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),

    UNIQUE (order_id, method)  -- ← natural idempotency constraint
);

-- Insert pertama: berhasil
INSERT INTO payments (order_id, method, amount, status)
VALUES ('ord-123', 'credit_card', 500000, 'pending');

-- Insert kedua (retry): ditolak database
INSERT INTO payments (order_id, method, amount, status)
VALUES ('ord-123', 'credit_card', 500000, 'pending');
-- ERROR: duplicate key value violates unique constraint "payments_order_id_method_key"
// Handling natural idempotency di aplikasi
func (r *PaymentRepo) CreatePayment(ctx context.Context, p *Payment) (*Payment, error) {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO payments (order_id, method, amount, status) VALUES ($1, $2, $3, $4)",
        p.OrderID, p.Method, p.Amount, p.Status,
    )
    if err != nil {
        // Cek apakah ini unique constraint violation (duplicate = idempotent retry)
        if isUniqueConstraintError(err) {
            // Return existing payment — ini adalah idempotent response
            return r.GetByOrderAndMethod(ctx, p.OrderID, p.Method)
        }
        return nil, err
    }
    return p, nil
}
Kelebihan natural idempotency:
  ✓ Lebih sederhana — tidak perlu tabel terpisah
  ✓ Atomic secara otomatis — database yang menjamin
  ✓ Tidak ada state machine yang perlu dikelola

Keterbatasan natural idempotency:
  ✗ Hanya bekerja untuk operasi insert (tidak untuk operasi yang lebih kompleks)
  ✗ Tidak menyimpan response dari eksekusi pertama
  ✗ Tidak menangani concurrent requests dengan baik
  → Gunakan bersama Idempotency-Key pattern untuk use case yang lebih kompleks

Concurrent Request dengan Key yang Sama #

Salah satu skenario yang perlu ditangani adalah dua request dengan idempotency key yang sama tiba hampir bersamaan — ini terjadi ketika client melakukan retry sangat cepat atau ada bug di client yang mengirim dua request sekaligus.

Skenario concurrent request:

T=0ms: Request A masuk dengan key-xyz
T=5ms: Request B masuk dengan key-xyz (sebelum A selesai)

Tanpa handling yang benar:
  Keduanya cek DB: key-xyz tidak ada
  Keduanya eksekusi business logic
  Double payment lagi!

Dengan atomic claim (INSERT ... ON CONFLICT):
  Request A: INSERT key-xyz → berhasil (affected rows = 1)
  Request B: INSERT key-xyz → gagal (UNIQUE constraint) → return 409
  Hanya Request A yang lanjut eksekusi
-- PostgreSQL: Atomic claim dengan INSERT ... ON CONFLICT DO NOTHING
INSERT INTO idempotency_keys (key, request_hash, status, user_id, endpoint, expires_at)
VALUES ($1, $2, 'processing', $3, $4, $5)
ON CONFLICT (key) DO NOTHING;

-- Cek apakah insert berhasil (affected rows)
-- Jika 0: key sudah ada → duplicate request
-- Jika 1: berhasil claim → lanjut eksekusi
func (r *Repo) ClaimIdempotencyKey(ctx context.Context, key *IdempotencyKey) error {
    result, err := r.db.ExecContext(ctx, `
        INSERT INTO idempotency_keys (key, request_hash, status, user_id, endpoint, expires_at)
        VALUES ($1, $2, 'processing', $3, $4, $5)
        ON CONFLICT (key) DO NOTHING`,
        key.Key, key.RequestHash, key.UserID, key.Endpoint, key.ExpiresAt,
    )
    if err != nil {
        return err
    }

    rowsAffected, _ := result.RowsAffected()
    if rowsAffected == 0 {
        return ErrDuplicateIdempotencyKey  // concurrent duplicate
    }
    return nil
}

Response Header yang Informatif #

Ketika mengembalikan cached response dari idempotency, tambahkan header yang membantu client dan operator membedakan response asli dari cached response.

Header yang berguna:

Idempotent-Replayed: true
  → Menandakan ini adalah cached response, bukan hasil eksekusi baru

X-Idempotency-Key: key-abc-123
  → Echo back key yang digunakan

X-Original-Request-Time: 2024-01-27T14:32:00Z
  → Kapan request original dieksekusi

Contoh response cached:
  HTTP 200 OK
  Idempotent-Replayed: true
  X-Idempotency-Key: key-abc-123
  X-Original-Request-Time: 2024-01-27T14:32:00Z
  Content-Type: application/json

  { "order_id": "ord_789", "status": "confirmed", "total": 500000 }

Ini berguna untuk:
  → Monitoring: track berapa banyak replay vs eksekusi baru
  → Debugging: bedakan response asli dan cached saat investigasi incident
  → Client: tahu apakah perlu update UI atau sudah up-to-date

Anti-Pattern Idempotency yang Harus Dihindari #

Key yang Terlalu Generik atau Bisa Ditebak #

// ✗ Anti-pattern: Key yang bisa collision
Idempotency-Key: user-123-payment
→ Jika user membeli dua kali dalam window TTL yang sama,
  pembelian kedua akan di-treat sebagai duplicate!

// ✓ Solusi: Key yang unik per operasi
Idempotency-Key: <uuid-v4 yang di-generate fresh untuk setiap operasi>
→ Client harus generate UUID baru untuk setiap operasi yang berbeda
→ Server tidak boleh generate key — itu tugas client

Tidak Menyimpan Response Asli #

// ✗ Anti-pattern: Hanya menyimpan "sudah diproses" tanpa response
if exists(idempotencyKey) {
    return genericSuccessResponse()  // bukan response asli!
}

// Masalah: response tidak konsisten antara eksekusi asli dan replay
// Client tidak bisa distinguish mana yang original, mana yang replay

// ✓ Solusi: Simpan response lengkap (body + status code)
// Saat replay, return PERSIS response yang sama
if existing.Status == "completed" {
    w.WriteHeader(existing.StatusCode)
    w.Write(existing.Response)  // byte-for-byte sama
}

Status “processing” yang Stuck #

// ✗ Anti-pattern: Status processing tidak di-handle ketika gagal
func processPayment() {
    claimKey()         // status: processing
    charge()           // jika panic di sini...
    updateKey("completed")  // ... ini tidak pernah dipanggil
}
// Key stuck di "processing" selamanya
// Semua retry dikira "masih diproses" dan dapat 409

// ✓ Solusi: Selalu update status ke "failed" jika ada error
defer func() {
    if r := recover(); r != nil {
        repo.UpdateStatus(ctx, key, "failed")
        panic(r)
    }
}()
// Atau gunakan defer dengan error yang ditangkap
// ✗ Anti-pattern: Key di-generate dan disimpan di client
// localStorage.setItem('idempotencyKey', uuid())
// → Key bisa dihapus user, browser refresh, incognito mode
// → Kehilangan key = retry tanpa proteksi idempotency

// ✓ Solusi: Key di-generate fresh untuk setiap operasi
// TAPI disimpan di state yang tepat (React state, server session)
// Atau: generate saat tombol Submit diklik, bukan saat halaman load

Checklist Implementasi Idempotency #

DESIGN:
  □ Idempotency wajib untuk operasi finansial (payment, transfer, refund)
  □ Idempotency diterapkan untuk operasi dengan side effect yang tidak reversible
  □ TTL dipilih sesuai dengan window retry yang diharapkan
  □ Status lifecycle didefinisikan: processing → completed / failed

IMPLEMENTATION:
  □ Idempotency-Key header diterima dan divalidasi (tidak boleh kosong)
  □ Request hash dihitung dari normalized payload
  □ Atomic claim menggunakan INSERT ... ON CONFLICT DO NOTHING
  □ Response asli tersimpan lengkap (body + status code)
  □ Status di-update ke "failed" jika business logic gagal (tidak stuck di processing)
  □ Header Idempotent-Replayed: true dikembalikan untuk cached response

SECURITY:
  □ Key dibatasi per user (user A tidak bisa pakai key yang dibuat user B)
  □ Request hash divalidasi untuk mendeteksi payload conflict
  □ Payload mismatch mengembalikan 422, bukan diam-diam mengignore

STORAGE:
  □ UNIQUE constraint ada di kolom key
  □ Index ada di (key, user_id) untuk lookup yang efisien
  □ Cleanup job berjalan secara berkala untuk key yang expired
  □ Storage yang dipilih sesuai kebutuhan (Redis untuk TTL native, DB untuk audit trail)

TESTING:
  □ Test: request yang sama dua kali hanya menghasilkan satu side effect
  □ Test: concurrent request dengan key yang sama hanya satu yang diproses
  □ Test: payload berbeda dengan key yang sama mengembalikan 422
  □ Test: expired key tidak lagi dianggap sebagai duplicate
  □ Test: status "failed" tidak memblokir retry dengan key yang sama

Ringkasan #

  • Idempotency adalah property, bukan fitur — sebuah operasi dikatakan idempotent jika menjalankannya satu atau beberapa kali menghasilkan state yang sama. GET dan DELETE idempotent by spec, POST tidak — tapi implementasi yang menentukan.
  • Retry terjadi lebih sering dari yang kamu kira — mobile SDK, load balancer, service mesh, dan message queue semuanya melakukan retry secara otomatis. Sistem yang tidak idempotent akan menghasilkan bug yang sulit direproduksi.
  • Idempotency-Key header adalah pattern paling umum — client generate UUID unik per operasi dan kirim sebagai header, server simpan dan gunakan untuk deduplikasi.
  • Atomic claim dengan INSERT … ON CONFLICT — cara paling sederhana untuk menangani concurrent request dengan key yang sama. Yang berhasil insert yang lanjut proses, yang gagal insert return 409.
  • Request hashing untuk deteksi payload conflict — key yang sama dengan payload berbeda adalah bug client atau percobaan abuse. Hash payload dan validasi konsistensinya.
  • Simpan response asli, bukan response generik — saat replay, kembalikan persis response yang sama (byte-for-byte). Ini penting untuk konsistensi dari perspektif client.
  • Status “failed” harus bisa di-retry — jangan biarkan key stuck di “processing” ketika business logic gagal. Gunakan defer untuk memastikan status selalu di-update.
  • TTL dipilih berdasarkan window retry — payment gateway biasanya retry dalam 24–48 jam, jadikan ini panduan TTL. Jangan terlalu pendek (retry menjadi berbahaya lagi) atau terlalu panjang (storage membengkak).
  • Natural idempotency via database constraint — untuk operasi yang lebih sederhana, UNIQUE constraint di kombinasi field yang tepat sudah cukup. Lebih sederhana dari full idempotency layer.
  • Monitoring replay rate — track berapa persen request yang merupakan idempotent replay vs eksekusi baru. Replay rate yang tinggi mengindikasikan masalah di sisi client atau infrastruktur.

← Sebelumnya: N+1 Query   Berikutnya: CSR →

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