Dalam membangun sistem Payment Middleware yang menjembatani Payment Gateway eksternal dengan Sistem Core ERP internal, kita berhadapan dengan dua sistem yang memiliki sifat dan siklus hidup yang sangat bertolak belakang:
ECONNRESET) ketika jaringan internal berfluktuasi atau saat worker ERP di-restart.Artikel ketiga dari seri portofolio engineering ini membahas dua studi kasus nyata yang saya selesaikan di proyek ini:
EXPIRED.ECONNRESET pada Koneksi Singleton RPCIntegrasi ke Core ERP menggunakan klien RPC yang diinisialisasi satu kali sebagai Singleton Instance saat aplikasi menyala. Masalah muncul setelah aplikasi berjalan selama berjam-jam:
isConnected = true), aplikasi mengira soket masih sehat.socket hang up, ECONNRESET, atau EPIPE.Jika error ini tidak ditangkap dan sesi tidak di-reset, maka panggilan-panggilan berikutnya juga akan terus gagal karena aplikasi terus menggunakan sesi soket yang sudah mati.
Untuk menyelesaikan masalah ini secara bersih dan reusable di seluruh modul, saya menerapkan pola Higher-Order Retry Function yang dikombinasikan dengan mekanisme forced reconnect.
Fungsi pembungkus (wrapper) ini secara spesifik membedakan antara Transient Network Error (yang wajib di-reconnect dan di-retry) dengan Business Logic Error dari ERP (seperti kesalahan validasi data, yang harus langsung dilempar tanpa retry):
// Prototype Konseptual: Transient Socket Retry & Auto-Reconnect Wrapper
const TRANSIENT_SOCKET_ERRORS = [
'ECONNRESET',
'ECONNREFUSED',
'EPIPE',
'ETIMEDOUT',
];
async function withRpcRetry<T>(
operation: () => Promise<T>,
rpcProvider: RpcConnectionProvider,
maxRetries = 1,
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
try {
return await operation();
} catch (err: any) {
const isSocketDrop =
TRANSIENT_SOCKET_ERRORS.includes(err?.code) ||
/socket hang up|ECONNRESET|ETIMEDOUT/i.test(err?.message || '');
if (isSocketDrop && attempt <= maxRetries) {
// Paksa reset state koneksi & lakukan handshake ulang ke Core ERP
await rpcProvider.forceReconnect();
await sleep(1000); // Jeda 1 detik sebelum mengulang operasi
continue;
}
throw err; // Lempar langsung jika merupakan error validasi bisnis
}
}
throw new Error('RPC retries exhausted');
}
Dengan pola desain ini, setiap kali soket terputus di tengah malam atau saat terjadi gangguan jaringan sesaat, aplikasi secara otomatis memulihkan koneksinya sendiri (self-healing connection) dalam waktu 1 detik tanpa menggagalkan transaksi pelanggan yang sedang berlangsung.
Kasus kedua ini adalah contoh nyata mengapa merancang sistem pembayaran membutuhkan pemahaman mendalam tentang perilaku pengguna dan perbedaan session lifecycle antar penyedia gateway.
Pada fitur Master Payment Link, pelanggan diberikan sebuah tautan pembayaran yang memiliki masa berlaku lokal (misalnya expired_at = 30 menit). Selama 30 menit tersebut, pelanggan bebas memilih metode pembayaran (VA, QRIS, E-Wallet, atau Kartu Kredit).
Namun, saat mengintegrasikan kanal Kartu Kredit, kami menemukan skenario edge case kritis berikut:
Perhatikan celah waktunya:
expired_at = 30 menit.ACTIVE menjadi EXPIRED. (Link pembayaran lokal sudah dikunci dan tidak bisa dibuka lagi).SUCCESS ke backend kita.Jika webhook handler secara kaku menolak setiap callback yang masuk ke transaksi berstatus EXPIRED:
EXPIRED.Untuk memastikan uang pelanggan tidak pernah hilang atau menggantung, saya merancang aturan state transition pada callback handler sebagai berikut:
EXPIRED Saat Menerima Callback SUCCESS:
SUCCESS adalah jika transaksi di database lokal sudah berstatus SUCCESS (untuk mencegah double processing / overpayment).EXPIRED di menit ke-30 tetap berfungsi menjaga agar pelanggan tidak bisa membuka kembali halaman Master Payment Link untuk meng-generate metode pembayaran baru.EXPIRED $\rightarrow$ SUCCESS):
EXPIRED menjadi SUCCESS, mencatat waktu lunas, dan menyimpan nomor referensi gateway.SUCCESS, sistem langsung melanjutkan eksekusi pembuatan invoice dan pencatatan pelunasan di Core ERP, serta mengirimkan notifikasi webhook ke sistem partner.// Prototype Konseptual: State Transition dengan Dukungan Late Payment Recovery
async function onPaymentSuccessWebhook(
orderCode: string,
payload: WebhookPayload,
) {
const order = await db.orders.findByCode(orderCode);
// 1. Idempotency Guard: Hanya blokir jika transaksi MEMANG SUDAH SUKSES
if (order.status === 'SUCCESS') {
return { acknowledged: true, reason: 'ALREADY_SETTLED' };
}
// 2. Observability untuk kasus Late Payment (uang masuk saat status lokal EXPIRED)
if (order.status === 'EXPIRED') {
logger.warn(
`[LatePaymentRecovery] Memulihkan order ${orderCode} dari EXPIRED ke SUCCESS`,
);
}
// 3. Pulihkan status menjadi SUCCESS & jalankan sinkronisasi penuh ke ERP
await db.orders.markAsPaid(order.id, payload.referenceNo);
await erpSyncService.createAndSettleInvoice(order.id);
await partnerWebhookService.notifyDownstream(order.id);
return { acknowledged: true };
}
ECONNRESET, socket hang up, EPIPE) ke dalam wrapper higher-order membuat seluruh sistem tahan banting terhadap gangguan jaringan sesaat.SUCCESS), sistem backend wajib memulihkan status transaksi (EXPIRED $\rightarrow$ SUCCESS) dan menyelesaikan penjurnalan akuntansi agar tidak ada dana pelanggan yang terkatung-katung.Pada Part 4 berikutnya, kita akan membedah bagaimana saya membangun modul Two-Phase Automated Reconciliation Worker yang secara otomatis memulihkan riwayat pembayaran yang hilang di Core ERP dan menyelesaikan pelunasan tertunda dengan resolusi mode jurnal dinamis.