Sistem pengiriman webhook dari penyedia layanan pembayaran (payment gateway) atau bursa eksternal beroperasi di atas prinsip at-least-once delivery. Kegagalan jaringan sesaat (network blip), timeout HTTP 504, atau proses restart internal pada gateway memicu mekanisme retry otomatis. Tanpa mekanisme kontrol konkurensi dan idempotensi yang ketat, satu notifikasi keberhasilan transfer dapat dieksekusi berkali-kali. Kasus double-fill atau duplikasi saldo pada platform seperti Robinhood adalah preseden buruk yang berakar dari kegagalan menangani konkurensi payload ini.
Anatomi Masalah: Network Retry, Duplikasi, dan Out-of-Order Delivery
Saat menangani webhook finansial, ada tiga skenario kegagalan utama yang wajib diantisipasi:
- Network Retry Acak: Server penerima telah selesai memperbarui balance di database, namun jaringan putus sebelum HTTP response
200 OKterkirim ke gateway. Gateway menganggap request gagal dan mengirimkan ulang request yang identik. - Concurrent Duplication: Dua worker webhook dari sisi gateway mengirim event yang sama persis dalam rentang milidetik akibat timeout prematur.
- Out-of-Order Delivery: Event
ORDER_FILLEDtiba mendahuluiORDER_CREATEDkarena latensi perutean jaringan atau pemrosesan asinkron multi-partisi di sisi publisher.
Desain Kontrak API dan Idempotency Key
Gateway finansial standar menyertakan identifier unik pada HTTP header atau payload root, misalnya header Idempotency-Key atau field event_id (contoh: Stripe, Xendit). Kontrak API harus menegakkan aturan:
- Jika request dengan
idempotency_keyyang sama sedang diproses (in-flight), kembalikan status409 Conflictatau tahan proses hingga lock selesai. - Jika request dengan
idempotency_keyyang sama telah selesai diproses (PROCESSED), respons wajib mengembalikan payload sukses sebelumnya secara deterministik tanpa mengeksekusi mutasi data ulang. - Key memiliki Time-To-Live (TTL). Di sistem perbankan, TTL umumnya dipasang 24 jam hingga 72 jam tergantung jendela retry gateway.
Skema Status Lock di PostgreSQL
Redis efisien untuk distributed locking jangka pendek, namun database relasional ACID tetap menjadi sumber kebenaran (single source of truth) permanen. Gunakan tabel pencatat event idempotensi dengan status eksplisit.
CREATE TABLE webhook_events (
idempotency_key VARCHAR(128) PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING, PROCESSED, FAILED
payload JSONB NOT NULL,
response_body JSONB NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_webhook_status ON webhook_events (status);
Implementasi: Redis Distributed Lock dan Transaksi Database
Berikut implementasi backend (Node.js/TypeScript) menggunakan Redis atomic operation (SET NX EX) sebagai gatekeeper lapisan pertama, dilanjutkan oleh transaksi database dengan isolasi record.
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 });
export async function handleFinancialWebhook(req: Request, res: Response) {
const idempotencyKey = req.headers['idempotency-key'] as string;
const { event_type, amount, account_id } = req.body;
if (!idempotencyKey) {
return res.status(400).json({ error: 'Missing Idempotency-Key header' });
}
// 1. Redis atomic lock (TTL 30 detik untuk menahan race condition)
const lockKey = `lock:webhook:${idempotencyKey}`;
const acquired = await redis.set(lockKey, 'locked', 'EX', 30, 'NX');
if (!acquired) {
// Konkurensi terdeteksi: request sedang dieksekusi thread lain
return res.status(409).json({ error: 'Concurrent request in progress' });
}
const client = await db.connect();
try {
// 2. Cek apakah event sudah pernah diproses di Postgres
const existingEvent = await client.query(
'SELECT status, response_body FROM webhook_events WHERE idempotency_key = $1',
[idempotencyKey]
);
if (existingEvent.rows.length > 0) {
const event = existingEvent.rows[0];
if (event.status === 'PROCESSED') {
// Return cached response deterministik
return res.status(200).json(event.response_body);
}
if (event.status === 'PENDING') {
return res.status(409).json({ error: 'Event currently being processed' });
}
}
// 3. Eksekusi mutasi saldo dalam satu unit transaksi
await client.query('BEGIN');
await client.query(
`INSERT INTO webhook_events (idempotency_key, event_type, status, payload)
VALUES ($1, $2, 'PENDING', $3)
ON CONFLICT (idempotency_key) DO NOTHING`,
[idempotencyKey, event_type, JSON.stringify(req.body)]
);
// Mutasi saldo: Atomic increment
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, account_id]
);
const responsePayload = { success: true, message: 'Balance updated' };
// Tandai event selesai diproses
await client.query(
`UPDATE webhook_events
SET status = 'PROCESSED', response_body = $1, updated_at = NOW()
WHERE idempotency_key = $2`,
[JSON.stringify(responsePayload), idempotencyKey]
);
await client.query('COMMIT');
return res.status(200).json(responsePayload);
} catch (error) {
await client.query('ROLLBACK');
return res.status(500).json({ error: 'Internal transaction error' });
} finally {
client.release();
// Hapus lock Redis agar request retry resmi berikutnya dapat diproses via cache DB
await redis.del(lockKey);
}
}
Menangani Out-of-Order Delivery
Jika webhook pembaruan status order tiba tidak berurutan, pendekatan idempotensi berbasis key saja tidak cukup. Dibutuhkan mekanisme sequence numbering atau state-machine verification:
- Entity Versioning: Sertakan field integer
versionpada baris akun/order di database. Query mutasi mewajibkan filterWHERE version = incoming_version - 1. Jika versi tidak cocok, tolak mutasi dan antrikan event ke Dead Letter Queue (DLQ). - Validasi Transisi State: Order dengan status
FILLEDatauCANCELLEDtidak boleh menerima transisi balik kePENDING. Gunakan explicit finite state machine (FSM) sebelum menerapkan mutasi data finansial.
Trade-off dan Jebakan Implementasi
- Pelepasan Lock Prematur: Jika waktu eksekusi transaksi database melebihi TTL lock Redis, lock akan kedaluwarsa sebelum transaksi selesai. Solusinya, buat TTL lock secukupnya (misal: 10-30 detik) dan pastikan transaksi DB dirancang sependek mungkin tanpa nested network call.
- External Call di Dalam Transaksi DB: Jangan pernah memanggil API pihak ketiga (misal: kirim notifikasi push atau email) di dalam blok transaksi PostgreSQL. Jika panggilan eksternal hang, row lock database akan menahan koneksi lain dan menyebabkan connection pool exhaustion.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!