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:#fffImplementasi 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
Menyimpan Idempotency Key di Client Cookie atau LocalStorage #
// ✗ 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.