Untuk endpoint pembayaran, idempotency bukan fitur tambahan, melainkan mekanisme inti untuk mencegah charge ganda saat klien melakukan retry. Desain yang aman harus memastikan bahwa request yang sama tidak mengeksekusi side effect dua kali, sementara request yang berbeda tidak boleh lolos hanya karena memakai Idempotency-Key yang sama.
Di Rust, implementasi Idempotency Key untuk API payment yang aman biasanya menggabungkan beberapa komponen: kontrak API yang jelas, penyimpanan status request yang konsisten, hashing payload untuk mendeteksi reuse yang tidak valid, kontrol balapan request paralel, dan kebijakan respons retry berdasarkan hasil sebelumnya. Fokus artikel ini adalah desain praktis yang bisa dipakai pada backend production, bukan sekadar contoh toy.
Kapan klien wajib mengirim Idempotency-Key
Aturan paling aman: wajibkan header Idempotency-Key untuk semua operasi payment yang menimbulkan side effect, misalnya create charge, authorize payment, capture, atau refund. Jangan mewajibkannya untuk endpoint GET karena GET semestinya sudah idempoten secara semantik HTTP.
Kontrak yang umum dan mudah diaudit:
- Wajib untuk request POST yang membuat transaksi pembayaran.
- Opsional atau tidak dipakai untuk operasi yang hanya membaca data.
- Ditolak jika kosong, terlalu panjang, atau formatnya tidak sesuai kebijakan.
Jika klien tidak mengirim key pada endpoint payment, respons yang masuk akal adalah 400 Bad Request atau 422 Unprocessable Entity, selama konsisten di seluruh API. Pilih salah satu dan dokumentasikan. Yang penting, jangan diam-diam memproses payment tanpa proteksi idempotensi jika kontraknya menyatakan key wajib.
Catatan: Jangan membuat server selalu menghasilkan Idempotency-Key otomatis untuk klien pada endpoint payment. Itu menghilangkan kemampuan retry yang deterministik dari sisi klien.
Menentukan scope Idempotency-Key
Kesalahan desain yang sering terjadi adalah menganggap Idempotency-Key unik secara global. Pada praktiknya, key perlu memiliki scope yang jelas agar tidak bentrok antar pengguna atau antar operasi.
Scope yang disarankan
Minimal, scope key sebaiknya mencakup:
- Tenant atau merchant
- Endpoint atau operation type, misalnya
create_payment - Idempotency-Key mentah dari klien
Dengan begitu, kombinasi unik yang disimpan bukan sekadar key, melainkan misalnya:
(merchant_id, operation, idempotency_key)Ini mencegah kasus merchant A dan merchant B kebetulan memakai key yang sama lalu saling mengganggu. Scope per operasi juga penting karena key untuk create_payment tidak seharusnya dianggap sama dengan key untuk refund_payment.
Kapan perlu menambah scope resource
Jika API Anda beroperasi terhadap resource tertentu, Anda bisa menambahkan resource_id ke scope. Namun untuk endpoint pembuatan payment, biasanya resource final belum ada sebelum request diproses, sehingga scope per merchant dan operation sudah cukup.
Trade-off: scope yang terlalu luas meningkatkan risiko collision logis; scope yang terlalu sempit bisa membuat deduplikasi gagal menangkap retry yang sebenarnya sama.
Payload hashing untuk mencegah reuse yang tidak valid
Idempotency-Key saja tidak cukup. Klien bisa saja secara tidak sengaja atau sengaja mengirim key yang sama dengan payload berbeda. Jika server hanya melihat key, request kedua dapat salah dianggap retry yang valid padahal isinya berubah, misalnya jumlah pembayaran berbeda.
Karena itu, simpan juga hash payload kanonik bersama key.
Apa yang perlu di-hash
Hash harus dihitung dari bagian request yang menentukan side effect bisnis, misalnya:
- merchant_id
- currency
- amount
- customer reference
- payment method token atau referensi yang relevan
- operation type
Header yang tidak relevan, timestamp klien, atau field acak yang tidak memengaruhi hasil bisnis sebaiknya tidak dimasukkan jika itu hanya menambah mismatch palsu.
Pentingnya canonicalization
Jangan langsung hash raw JSON body apa adanya jika format serialisasi bisa berubah urutan field-nya. Gunakan representasi kanonik yang stabil. Cara praktis:
- Parse body ke struct Rust yang tervalidasi.
- Bentuk object internal yang hanya memuat field relevan.
- Serialize secara konsisten.
- Hash hasil serialisasi dengan algoritma kriptografis umum.
Tujuannya bukan kerahasiaan, melainkan deteksi perubahan payload secara deterministik.
Jika request datang dengan Idempotency-Key yang sama tetapi hash payload berbeda, respons yang aman adalah 409 Conflict atau 422. Banyak sistem memilih 409 karena konflik terjadi pada reuse key untuk request yang berbeda.
Status request yang perlu disimpan
Untuk payment API, penyimpanan idempotensi idealnya bukan sekadar cache boolean “sudah pernah diproses”. Anda perlu menyimpan state machine request agar server tahu apakah request masih berjalan, sudah berhasil, gagal validasi, atau gagal sementara.
Status minimum yang praktis
- processing: request sedang diproses, side effect mungkin belum atau sedang berlangsung
- succeeded: side effect berhasil, respons final bisa dikembalikan ulang
- failed_client: gagal karena kesalahan klien, misalnya validasi atau business rule
- failed_server: gagal karena error internal atau hasil akhir tidak pasti
Kolom yang lazim disimpan:
- scope: merchant_id, operation, idempotency_key
- request_hash
- status
- http_status_code
- response_body yang aman untuk dikembalikan ulang
- payment_id atau external_reference bila sudah terbentuk
- error_code internal
- created_at, updated_at, expires_at
Skema tabel sederhana
CREATE TABLE idempotency_records (
merchant_id TEXT NOT NULL,
operation TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL,
http_status_code INTEGER,
response_body TEXT,
payment_id TEXT,
error_code TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (merchant_id, operation, idempotency_key)
);Jika database mendukung, tambahkan indeks pada expires_at untuk memudahkan pembersihan data kedaluwarsa.
TTL: berapa lama record idempotensi disimpan
TTL harus cukup lama untuk menampung retry realistis dari klien, job network yang tertunda, dan investigasi insiden. Untuk payment, masa simpan biasanya tidak terlalu pendek karena risiko duplicate charge lebih mahal daripada biaya storage tambahan.
Prinsip umumnya:
- Jangan terlalu pendek hingga retry beberapa menit atau jam kemudian malah diperlakukan sebagai request baru.
- Jangan terlalu panjang jika akan membebani storage tanpa manfaat operasional.
- Selaraskan dengan pola retry SDK/klien dan SOP support Anda.
Jika record sudah melewati TTL dan dibersihkan, request dengan key yang sama akan dianggap baru. Karena itu, dokumentasikan TTL secara eksplisit agar klien tahu jendela retry yang dijamin.
Praktik yang aman: gunakan TTL yang lebih panjang untuk operasi payment daripada operasi non-finansial, lalu jalankan proses cleanup terjadwal. Hindari menghapus terlalu agresif.
Perilaku respons saat retry
Bagian paling penting dari kontrak API adalah apa yang dikembalikan server saat menerima retry dengan Idempotency-Key yang sama. Respons harus bergantung pada status request sebelumnya.
Jika request pertama berhasil 2xx
Kembalikan respons yang sama seperti percobaan pertama, idealnya termasuk body yang sama secara semantik. Ini membuat retry aman dan mudah dipahami klien.
Jika request pertama gagal 4xx
Untuk error validasi atau business rule yang deterministik, Anda bisa mengembalikan 4xx yang sama untuk retry dengan payload yang sama. Ini masuk akal karena request yang sama memang tidak valid.
Namun ada nuance: bila 4xx terjadi sebelum side effect apa pun dan Anda tidak ingin “mengunci” key untuk request invalid, Anda bisa memilih untuk tidak menyimpan hasil 4xx tertentu. Trade-off-nya, klien yang retry mungkin memicu validasi ulang terus-menerus. Untuk payment, menyimpan hasil 4xx yang deterministik biasanya lebih mudah diaudit.
Jika request pertama gagal 5xx
Ini kasus paling sensitif. Tidak semua 5xx berarti aman untuk dieksekusi ulang. Pada payment, ada dua kemungkinan:
- Gagal sebelum side effect: retry dapat mencoba proses lagi.
- Timeout atau error setelah side effect berhasil: retry tidak boleh mengeksekusi side effect kedua kali.
Karena itu, jangan menyederhanakan aturan menjadi “kalau 5xx maka hapus record lalu biarkan retry bikin transaksi baru”. Sebaliknya, simpan state yang membedakan hasil pasti dan hasil tidak pasti.
Jika request masih processing
Untuk request paralel atau retry cepat ketika proses pertama masih berjalan, server bisa:
- mengembalikan 409 Conflict atau 425/429-style semantic sesuai kebijakan API internal, atau
- mengembalikan status khusus bahwa request sedang diproses dan klien perlu retry lagi.
Yang terpenting adalah jangan menjalankan side effect dua kali hanya karena dua request masuk berdekatan.
Edge case yang wajib ditangani
1. Timeout setelah side effect berhasil
Ini skenario klasik: server sudah menulis payment ke database atau berhasil memanggil provider internal, tetapi koneksi ke klien putus sebelum respons 201 terkirim. Klien mengira request gagal lalu mengirim ulang dengan key yang sama.
Jika record idempotensi disimpan dan diperbarui ke succeeded sebelum respons dikirim, retry berikutnya bisa mengembalikan hasil sukses yang sama tanpa membuat charge kedua. Inilah alasan mengapa status idempotensi harus berada di jalur transaksi bisnis, bukan sekadar logging samping.
2. Race condition dari request paralel
Dua request identik dengan key yang sama bisa masuk hampir bersamaan dari retry otomatis, double click, atau load balancer. Solusinya adalah membuat pembuatan record idempotensi bersifat atomik.
Pola yang umum:
- Coba insert record dengan status
processing. - Jika insert sukses, request ini menjadi pemroses utama.
- Jika insert gagal karena kunci unik sudah ada, baca record yang ada lalu putuskan respons berdasarkan statusnya.
Dengan cara ini, hanya satu request yang boleh lanjut ke side effect.
3. Duplicate submit dari UI
Masalah ini sering berasal dari front-end, tetapi backend tetap harus aman. Tombol submit yang ditekan dua kali atau aplikasi mobile yang resend karena jaringan buruk seharusnya tetap menghasilkan satu transaksi bisnis untuk satu key dan payload yang sama.
4. Reuse key dengan payload berbeda
Ini harus dianggap kesalahan klien, bukan retry. Kembalikan konflik, dan sertakan pesan yang menjelaskan bahwa key sudah dipakai untuk request lain.
Alur deduplikasi yang disarankan
Berikut pseudocode yang cukup dekat dengan implementasi production:
receive request
validate required headers
extract merchant_id, operation, idempotency_key
parse and validate request body
canonical_hash = hash(relevant_request_fields)
try insert idempotency_record(status=processing, request_hash=canonical_hash)
if insert succeeds:
primary processor = true
else:
primary processor = false
if not primary processor:
existing = load idempotency_record
if existing.request_hash != canonical_hash:
return 409 conflict
if existing.status == succeeded or existing.status == failed_client:
return existing.http_status_code + existing.response_body
if existing.status == processing:
return 409 in_progress
if existing.status == failed_server:
return policy_based_response
begin business operation
try:
payment = create payment / execute side effect
response = success payload
update idempotency_record(status=succeeded, http_status_code=201, response_body=response, payment_id=payment.id)
return 201 response
catch client_error:
response = error payload
update idempotency_record(status=failed_client, http_status_code=4xx, response_body=response)
return 4xx response
catch ambiguous_error:
update idempotency_record(status=failed_server, http_status_code=500, response_body=generic_error)
return 500 responsePada cabang failed_server, kebijakan retry perlu ditentukan hati-hati. Jika Anda punya cara untuk mengecek apakah payment sebenarnya sudah tercipta, lakukan rekonsiliasi internal sebelum memutuskan menjalankan side effect lagi.
Contoh implementasi dengan Axum
Contoh berikut menunjukkan struktur handler Axum yang berfokus pada kontrak idempotensi. Kode ini disederhanakan agar tetap ringkas, tetapi pola utamanya realistis: validasi header, hash payload, insert atomik, lalu replay respons.
use axum::{extract::State, http::{HeaderMap, StatusCode}, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
repo: Arc<dyn IdempotencyRepo>,
payment_service: Arc<dyn PaymentService>,
}
#[derive(Deserialize, Serialize)]
struct CreatePaymentRequest {
amount: i64,
currency: String,
customer_ref: String,
}
#[derive(Serialize, Clone)]
struct PaymentResponse {
payment_id: String,
status: String,
}
async fn create_payment(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreatePaymentRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let merchant_id = "mrc_123"; // ambil dari auth context
let operation = "create_payment";
let key = headers
.get("Idempotency-Key")
.and_then(|v| v.to_str().ok())
.filter(|v| !v.is_empty())
.ok_or_else(|| err(StatusCode::BAD_REQUEST, "missing Idempotency-Key"))?;
validate_request(&req).map_err(|msg| err(StatusCode::UNPROCESSABLE_ENTITY, msg))?;
let request_hash = canonical_request_hash(merchant_id, operation, &req);
match state.repo.try_begin(merchant_id, operation, key, &request_hash).await {
BeginResult::Started => {
let result = state.payment_service.create_payment(&req).await;
match result {
Ok(payment) => {
let body = serde_json::json!(PaymentResponse {
payment_id: payment.id,
status: "succeeded".into(),
});
state.repo
.store_success(merchant_id, operation, key, 201, &body, &payment.id)
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "failed to persist idempotency result"))?;
Ok((StatusCode::CREATED, Json(body)))
}
Err(PaymentError::Client(msg)) => {
let body = serde_json::json!({ "error": msg });
state.repo
.store_client_error(merchant_id, operation, key, 422, &body)
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "failed to persist idempotency result"))?;
Err((StatusCode::UNPROCESSABLE_ENTITY, Json(body)))
}
Err(PaymentError::Server) => {
let body = serde_json::json!({ "error": "internal error" });
state.repo
.store_server_error(merchant_id, operation, key, 500, &body)
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "failed to persist idempotency result"))?;
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(body)))
}
}
}
BeginResult::Replay(existing) => {
if existing.request_hash != request_hash {
return Err(err(StatusCode::CONFLICT, "Idempotency-Key already used for different payload"));
}
let status = StatusCode::from_u16(existing.http_status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
if existing.is_error {
Err((status, Json(existing.response_body)))
} else {
Ok((status, Json(existing.response_body)))
}
}
BeginResult::InProgress(existing) => {
if existing.request_hash != request_hash {
return Err(err(StatusCode::CONFLICT, "Idempotency-Key already used for different payload"));
}
Err(err(StatusCode::CONFLICT, "request is already being processed"))
}
}
}
fn err(status: StatusCode, message: &str) -> (StatusCode, Json<serde_json::Value>) {
(status, Json(serde_json::json!({ "error": message })))
}
fn validate_request(req: &CreatePaymentRequest) -> Result<(), &'static str> {
if req.amount <= 0 {
return Err("amount must be positive");
}
if req.currency.is_empty() {
return Err("currency is required");
}
Ok(())
}
fn canonical_request_hash(merchant_id: &str, operation: &str, req: &CreatePaymentRequest) -> String {
let canonical = serde_json::json!({
"merchant_id": merchant_id,
"operation": operation,
"amount": req.amount,
"currency": req.currency,
"customer_ref": req.customer_ref,
});
// ganti dengan hash kriptografis yang sesuai
canonical.to_string()
}Beberapa catatan penting dari contoh di atas:
- Hash harus benar-benar kriptografis pada implementasi nyata, bukan sekadar
to_string(). - try_begin harus bergantung pada constraint unik database agar aman terhadap request paralel.
- response_body yang disimpan sebaiknya aman untuk direplay; hindari menyimpan data sensitif yang tidak perlu.
Penyimpanan: database vs cache
Untuk payment, database transaksional umumnya pilihan utama dibanding cache volatile. Alasannya sederhana: Anda butuh durability, auditability, dan konsistensi yang lebih kuat.
Kapan database lebih tepat
- Side effect bersifat finansial
- Perlu rekam jejak investigasi
- Butuh unique constraint atomik
- Ingin menyimpan respons final untuk replay
Kapan cache bisa membantu
Cache seperti Redis bisa dipakai sebagai akselerator atau lock sementara, tetapi jangan menjadikannya satu-satunya sumber kebenaran untuk payment jika ada risiko kehilangan data saat restart atau failover. Jika tetap memakai cache, pastikan desain fallback dan persistensinya jelas.
Kesalahan umum yang perlu dihindari
- Menganggap key unik global tanpa merchant scope.
- Tidak membandingkan payload hash sehingga key bisa direuse untuk nominal berbeda.
- Hanya menyimpan flag sukses tanpa status processing dan error.
- Menghapus record terlalu cepat sehingga retry setelah timeout membuat charge baru.
- Tidak menyimpan response body final sehingga replay menghasilkan jawaban berbeda dari request pertama.
- Mengandalkan in-memory map per instance pada deployment multi-node.
Checklist observabilitas
Idempotensi yang benar juga harus mudah di-debug. Minimal, siapkan observabilitas berikut:
- Structured log dengan field: merchant_id, operation, idempotency_key, request_hash, status_transisi, payment_id
- Metric counter untuk: key baru, replay sukses, conflict hash mismatch, in-progress hit, failed_server
- Latency metric untuk durasi dari status processing ke status final
- Alert jika jumlah
failed_serveratauin-progressabnormal - Trace correlation agar idempotency key terlihat di span utama request
Hindari menaruh payload sensitif penuh ke log. Jika perlu, log field yang sudah disanitasi atau hash-nya saja.
Checklist pengujian integrasi
Unit test saja tidak cukup. Anda perlu integration test terhadap database sungguhan atau lingkungan yang cukup menyerupai production.
Skenario uji minimal
- Request pertama sukses, retry sukses
Kirim dua request identik dengan key sama. Pastikan side effect hanya satu kali dan respons kedua adalah replay. - Key sama, payload berbeda
Pastikan respons adalah conflict dan side effect kedua tidak dieksekusi. - Request paralel identik
Luncurkan dua request bersamaan. Verifikasi hanya satu yang menjadi pemroses utama. - Timeout setelah side effect berhasil
Simulasikan kegagalan pengiriman respons setelah payment tercipta. Retry harus mengembalikan hasil sukses yang sama. - 4xx deterministik direplay
Request invalid dengan key sama harus menghasilkan respons error yang konsisten. - 5xx ambigu
Pastikan kebijakan internal untuk statusfailed_serversesuai desain, dan tidak menyebabkan side effect ganda. - TTL expiry
Setelah masa simpan habis, verifikasi request diperlakukan sebagai baru sesuai kontrak.
Tips debugging
- Jika terlihat ada duplicate charge, periksa apakah unique constraint benar-benar aktif di level database.
- Jika replay tidak konsisten, cek apakah response body disimpan setelah side effect final, bukan sebelum.
- Jika banyak conflict palsu, audit canonicalization hash; mungkin ada field tidak stabil yang ikut di-hash.
Rekomendasi desain yang aman dan sederhana
Jika Anda ingin baseline yang kuat untuk endpoint payment di Rust, gunakan aturan berikut:
- Wajibkan Idempotency-Key pada semua POST payment.
- Scope key minimal: merchant_id + operation + idempotency_key.
- Simpan request_hash dari payload kanonik.
- Gunakan insert atomik ke database dengan status awal
processing. - Replay respons untuk hasil 2xx dan 4xx deterministik.
- Tangani 5xx secara hati-hati karena hasil bisa ambigu.
- Simpan record dengan TTL yang cukup panjang untuk jendela retry realistis.
- Lengkapi dengan log, metric, trace, dan integration test.
Dengan desain ini, API payment Anda tidak hanya tahan terhadap retry biasa, tetapi juga lebih aman menghadapi timeout, duplicate submit, dan request paralel. Pada sistem finansial, itulah perbedaan antara retry yang aman dan insiden charge ganda yang sulit direkonsiliasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!