Jaringan publik tidak menjamin pengiriman pesan tepat satu kali (exactly-once delivery). Payment gateway menerapkan kebijakan at-least-once delivery, mengirimkan ulang payload webhook ketika koneksi mengalami timeout atau server lambat merespons. Tanpa mekanisme idempoten, lonjakan retry otomatis ini memicu mutasi ganda seperti kredit saldo ganda atau pemenuhan pesanan berulang.
Plastisitas Sistem: Menangani Rangsangan Event Berulang
Konsep neuroplastisitas biologis menggambarkan bagaimana sinapsis belajar merespons rangsangan berulang: aktivasi awal mengubah struktur, sedangkan rangsangan identik berikutnya diperlakukan sebagai penguatan sinyal, bukan sinyal baru yang berdiri sendiri. Sistem backend pembayaran harus mengadopsi ketahanan serupa. Ketika event webhook yang sama tiba berkali-kali, sistem harus mempertahankan status final tanpa mengeksekusi efek samping (side effects) baru, sambil tetap mengembalikan status sukses (HTTP 200 OK) ke gateway.
Anatomi Race Condition pada Webhook
Kegagalan umum terjadi saat gateway mengirim dua request webhook identik secara paralel dalam rentang milidetik akibat jeda jaringan sementara. Alur non-idempoten yang rentan:
- Thread A membaca status transaksi dari database:
PENDING. - Thread B membaca status transaksi yang sama sebelum Thread A selesai:
PENDING. - Thread A memproses inventaris dan mengubah status menjadi
PAID. - Thread B menjalankan logika yang sama, memotong stok untuk kedua kalinya, lalu menimpa status ke
PAID.
Pemeriksaan manual berbasis SELECT di tingkat aplikasi gagal mencegah kondisi ini. Solusi teknis membutuhkan tiga lapis pengamanan: verifikasi signature payload, distributed locking sementara, dan database unique constraint.
Arsitektur Penanganan Idempoten
1. Verifikasi Signature Payload
Sebelum memproses data, autentikasi pengirim menggunakan HMAC-SHA256 untuk memastikan payload tidak dimanipulasi.
2. Distributed Lock (Redis)
Gunakan Redis key berbasis ID transaksi unik dengan masa kedaluwarsa singkat (TTL 10-30 detik) untuk memblokir eksekusi simultan antar-worker.
3. Database Level Atomic Constraint
Tabel log webhook atau event harus memiliki indeks unik pada kolom idempotency_key atau event_id. Jika locking gagal atau timeout, integritas basis data tetap terjaga oleh atomic rollback.
Implementasi Kode: Node.js & PostgreSQL
import crypto from 'node:crypto';
import { Request, Response } from 'express';
import Redis from 'ioredis';
import { Pool } from 'pg';
const redis = new Redis(process.env.REDIS_URL);
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const WEBHOOK_SECRET = process.env.GATEWAY_WEBHOOK_SECRET;
export async function handlePaymentWebhook(req: Request, res: Response) {
const signature = req.headers['x-signature'] as string;
const rawBody = req.body; // Buffer mentah diperlukan untuk HMAC
// 1. Verifikasi Signature HMAC-SHA256
const computedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (!signature || signature !== computedSignature) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody.toString());
const { event_id: eventId, order_id: orderId, status } = payload;
const lockKey = `lock:webhook:${eventId}`;
// 2. Akuisisi Distributed Lock (SET NX PX)
// ponytail: lock sederhana single-node, ganti redlock jika cluster terdistribusi
const acquired = await redis.set(lockKey, 'processing', 'PX', 15000, 'NX');
if (!acquired) {
// Sedang diproses oleh worker lain, kirim 429 atau 200 aman
return res.status(429).send('Event currently being processed');
}
const client = await db.connect();
try {
await client.query('BEGIN');
// 3. Catat Event Log dengan Unique Constraint
// Jika event_id sudah ada di DB, lewati mutasi bisnis
const logInsert = await client.query(
`INSERT INTO processed_webhooks (event_id, order_id, status)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING
RETURNING id`,
[eventId, orderId, status]
);
if (logInsert.rowCount === 0) {
// Event sudah pernah diproses tuntas sebelumnya
await client.query('ROLLBACK');
return res.status(200).json({ message: 'Event already processed' });
}
// 4. Atomic Mutation State Transaksi
if (status === 'SUCCESS') {
const updateOrder = await client.query(
`UPDATE orders
SET status = 'PAID', updated_at = NOW()
WHERE id = $1 AND status != 'PAID'`,
[orderId]
);
if (updateOrder.rowCount > 0) {
// Eksekusi mutasi tambahan (stok, pengiriman) di dalam transaksi
await client.query(
`UPDATE inventory SET stock = stock - 1 WHERE order_id = $1`,
[orderId]
);
}
}
await client.query('COMMIT');
return res.status(200).json({ message: 'Webhook processed successfully' });
} catch (error) {
await client.query('ROLLBACK');
// Error jaringan internal -> Gateway harus retry
return res.status(500).send('Internal transaction failure');
} finally {
client.release();
await redis.del(lockKey);
}
}
Desain Skema Database
Terapkan tabel deduplikasi untuk menjaga riwayat event masuk:
CREATE TABLE processed_webhooks (
id SERIAL PRIMARY KEY,
event_id VARCHAR(128) NOT NULL,
order_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_webhook_event_id UNIQUE (event_id)
);
CREATE INDEX idx_orders_status ON orders(id, status);
Trade-offs dan Tips Operasional
- Fast Ack vs Background Processing: Jika logika bisnis membutuhkan waktu >2 detik (misalnya integrasi API pihak ketiga atau pengiriman email), simpan payload langsung ke message queue (seperti RabbitMQ atau SQS) di dalam transaksi webhook, balas HTTP 200 secepatnya, lalu proses secara asinkron.
- Status Retain: Jangan pernah mengembalikan kode status
4xxuntuk webhook valid yang berhasil dideduplikasi. Gateway akan terus melakukan retry secara agresif jika tidak menerima respons status2xx. - Unordered Events: Gateway tidak menjamin urutan event (misal: event
FAILEDbisa tiba lebih lambat dariEXPIRED). Pastikan transisi status basis data bersifat searah menggunakan status finite-state machine (FSM) yang eksplisit.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!