Kasus ini membahas request timeout berulang di Express.js yang disebabkan oleh kebocoran pool koneksi Postgres selama retry otomatis. Penyebab utamanya adalah koneksi tidak dilepas saat error async terjadi, sehingga pool habis dan permintaan baru menunggu timeout. Artikel ini langsung menjelaskan gejala, observabilitas, analisis akar masalah, serta solusi praktis.

1. Gejala dan Langkah Reproduksi

Tim menemukan permintaan API yang mengakses database menampilkan timeout sekitar 30 detik. Timeout ini konsisten terjadi setelah spike traffic dan hanya pada endpoint yang melakukan query berat dengan mekanisme retry terhadap koneksi Postgres.

Langkah reproduksi:

  • Kirim 50 permintaan paralel ke endpoint tertentu.
  • Paksa Postgres menolak koneksi (misalnya dengan MAX_CONNECTIONS rendah) sehingga retry middleware dijalankan.
  • Amati waktu response dan jumlah koneksi aktif di pool (lihat pg_stat_activity).

Hasil: beberapa permintaan berhasil, tapi banyak yang hang >30 detik sambil pool terus bertambah, sampai mencapai batas.

2. Observabilitas: Log dan Metrics

Untuk menelusuri, tambahkan logging dan metrics pada middleware retry:

  • Log sebelum membuka koneksi dan setelah query selesai.
  • Track Event "acquire" dan "release" pada pool pg.
  • Tambahkan metric Prometheus: postgres_pool_acquires_total dan postgres_pool_releases_total.

Contoh log sederhana:

2024-10-12T10:02:01.345Z express-app Request=312 start postgres query
2024-10-12T10:02:01.348Z express-app Postgres pool acquired (active=16, idle=0)
2024-10-12T10:02:31.349Z express-app Postgres pool leak detected: still active after timeout

Perhatikan: tidak ada log "release" untuk banyak percobaan retry.

3. Analisis Root Cause

Retry middleware berfungsi dengan cara menangkap error query dan memanggil ulang fungsi query. Masalah terjadi ketika error async dilempar sebelum koneksi dilepas. Contoh kesalahan umum:

const client = await pool.connect();
try {
  return await client.query(sql, params);
} catch (err) {
  await performRetry(err); // error handling yang melempar lagi tanpa client.release()
}

Jika performRetry menolak Promise baru tanpa mengembalikan, maka client.release() tidak pernah terpanggil. Koneksi tetap menunggu, akhirnya pool habis. Root cause: error handling async (retry) tidak menjamin release dalam finally block.

4. Solusi Praktis

4.1 Perbaikan Middleware Retry

Penting memastikan koneksi selalu dilepas, termasuk saat retry. Struktur yang aman:

async function queryWithRetry(sql, params, retries = 2) {
  const client = await pool.connect();
  try {
    return await client.query(sql, params);
  } catch (err) {
    if (retries > 0 && shouldRetry(err)) {
      return queryWithRetry(sql, params, retries - 1);
    }
    throw err;
  } finally {
    client.release();
  }
}

Penjelasan: finally memastikan release dipanggil sekali per koneksi. Retry dilakukan dengan return agar Promise tetap chain dan release di finally tetap eksekusi.

4.2 Handling Error Async dengan Middleware Express

Jika ada middleware khusus retry, pastikan hanya memanggil next(err) setelah release dan tanpa menunda response forever.

app.use(async (req, res, next) => {
  try {
    await queryWithRetry(...);
    res.send({ ok: true });
  } catch (err) {
    next(err);
  }
});

Jangan menaruh retry di middleware lain yang tidak menyertakan finally; jika perlu, buat helper reusable seperti withPgClient.

4.3 Konfigurasi Pool Postgres

Beberapa pengaturan pool membantu mendeteksi kebocoran:

  • idleTimeoutMillis agar idle client dilepas otomatis.
  • max disesuaikan dengan kapasitas DB dan concurrency.
  • Gunakan event listener:
pool.on('acquire', () => metrics.inc('pool_acquire'));
pool.on('remove', () => metrics.inc('pool_remove'));

Event "remove" menandakan pool menutup koneksi yang tidak sehat.

5. Checklist Perbaikan

  • ✅ Gunakan try/finally untuk selalu memanggil client.release().
  • ✅ Pastikan retry hanya terjadi di dalam fungsi yang mengurus lifecycle koneksi.
  • ✅ Tambahkan log "acquire"/"release" dan bandingkan jumlah metric.
  • ✅ Sesuaikan ukuran pool dan timeout supaya tidak over-commit.
  • ✅ Tangani error express dengan next(err) setelah memastikan semua resource dilepas.

6. Verifikasi Regresi

Langkah-langkah untuk memastikan bug tidak muncul lagi:

  1. Automasi test stress: jalankan 100 paralel request dan pastikan rata-rata response tetap di bawah 800 ms.
  2. Monitoring pool: postgres_pool_acquires_total == postgres_pool_releases_total dalam window 5 menit.
  3. Simulasikan error Postgres agar retry aktif, pastikan tidak ada koneksi menumpuk di pg_stat_activity.
  4. Periksa log produksi untuk entri timeout; idealnya tidak ada setelah perbaikan.

Jika semua langkah di atas terpenuhi, regresi sudah dicegah.

Kesimpulan

Permasalahan timeout Express.js dengan pool Postgres biasanya berakar dari koneksi yang tidak dilepas saat retry async gagal. Struktur kode dengan finally, logging metrics pool, dan konfigurasi pool yang tepat akan mencegah kebocoran dan memastikan aplikasi tetap responsif.