Masalah transaksi ganda di CodeIgniter 4 umumnya muncul saat client mengirim ulang permintaan pembayaran yang sempat gagal, dan sistem backend tidak membedakan permintaan baru dari retry. Untuk kasus ini, observasi log awal langsung menunjukkan pola: entri transaksi duplikat, status HTTP 409 pada response kedua, dan header retry seperti X-Retry-Reason. Artikel ini membahas bagaimana akar masalah hilangnya idempotensi atau mekanisme locking sederhana, lalu menyiapkan debugging dan perbaikan menyeluruh.

Gejala yang Terlihat di Log dan Metrik

Tim monitoring menemukan pola berikut di development logging dan dashboard API gateway:

  • Request pembayaran dari client yang sama muncul dua kali dalam selang detik, ditandai dengan trace_id yang berbeda tetapi payload identik.
  • Entitas transactions mendapatkan dua baris hampir bersamaan, walau skema meja seharusnya validasi invoice_id unik.
  • Response pertama berhasil (HTTP 200), response kedua dikastrasi jadi HTTP 409 karena constraint jelas, tetapi client tetap mencatat pembayaran selesai karena response 200 pertama.
  • Header X-Retry-Reason: timeout atau Retry-After hadir pada pengiriman ulang, menandakan client melakukan retry otomatis.

Gejala ini membentuk hipotesis bahwa backend memproses retry tanpa memeriksa idempotensi atau eksklusivitas pada row yang sama.

Analisis Root Cause: Idempotensi dan Lock yang Hilang

Permintaan pembayaran harus idempotent: menerima beberapa kali request yang sama tidak boleh menambah jumlah transaksi. Di kasus ini ada dua kekurangan:

  1. Tidak ada idempotent key: API hanya menerima invoice_id tapi tidak menyimpan kunci unik untuk permintaan (misal header X-Idempotency-Key), sehingga setiap retry dianggap transaksi baru.
  2. Locking transaksi tidak ada: Transaksi dibuat di tabel transactions tanpa FOR UPDATE dan tanpa flag ongoing, sehingga kedua proses bisa baca lalu tulis secara almost-simultan.

Tanpa observabilitas tambahan pun, sulit membedakan apakah response pertama benar-benar selesai sebelum retry diproses.

Langkah Perbaikan Praktis

1. Request Tracing dengan Middleware

Tambahkan filter HTTP untuk menangkap header retry dan menetapkan trace_id supaya semua log bisa dilacak ke satu permintaan logis. Contoh filter sederhana:

namespace Appilters;
use CodeIgniteriltersilterInterface;
use CodeIgniteriltersilterTrait;
use CodeIgniter
outer
outers
outer;
use CodeIgniter
eactor
equest;
use CodeIgniter
eactor
esponse;

class RequestTrace implements FilterInterface
{
    use FilterTrait;

    public function before(RequestInterface $request, $arguments = null)
    {
        $traceId = $request->getHeaderLine('X-Trace-Id');
        if (empty($traceId)) {
            $traceId = bin2hex(random_bytes(16));
        }
        Services::request()->setGlobal('trace_id', $traceId);
        log_message('info', "trace_id={$traceId} method={$request->getMethod()} uri={$request->getURI()}");
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
        // optional response header
        $response->setHeader('X-Trace-Id', Services::request()->getGlobal('trace_id'));
    }
}

Daftarkan filter ini di app/Config/Filters.php agar setiap permintaan pembayaran ter-tag.

2. Menetapkan Kunci Idempotent

Tambahkan kolom idempotency_key pada tabel transactions dan buat logika yang memeriksa kunci tersebut sebelum membuat transaksi baru.

$idempotencyKey = $this->request->getHeaderLine('X-Idempotency-Key');
if (empty($idempotencyKey)) {
    throw new 
untimeException('Idempotency key wajib.');
}

$existing = $this->transactionModel
    ->where('idempotency_key', $idempotencyKey)
    ->where('status', 'completed')
    ->first();
if ($existing) {
    return $existing;
}

$this->db->transStart();
$transactionId = $this->transactionModel->insert([... 'idempotency_key' => $idempotencyKey]);
$this->db->transComplete();

Dengan pola ini, retry akan segera mengembalikan hasil transaksi yang sebelumnya sudah ada tanpa membuat baris baru.

3. Locking Sederhana untuk Memproses Transaksi

Untuk memastikan request paralel tidak melewati validasi, gunakan locking baris sebelum melakukan insert/update:

START TRANSACTION;
SELECT id FROM transactions WHERE invoice_id = ? FOR UPDATE;
-- lakukan insert/update setelah kunci diperoleh
COMMIT;

Implementasi di CodeIgniter bisa menggunakan query builder dengan FOR UPDATE dan block transaction, lalu apabila tidak ada row, baru insert. Ini mencegah dua thread membuat record dengan invoice yang sama.

4. Observabilitas dan Monitoring

Sertakan metrik tambahan seperti counter idempotent_hit dan log HTTP 409. Jika terjadi HTTP 409 karena duplicate, tambahkan detail trace_id dan idempotency_key pada log agar mudah dikorelasi. Pastikan dashboard menampilkan latency distribusi saat lock diambil.

Regresi dan Validasi

Buat skenario pengujian regresi berikut:

  1. Simulasikan client mengirim POST /payment dengan payload yang sama, header X-Idempotency-Key: 12345, dan ulangi permintaan kedua setelah 200 ms.
  2. Pastikan response pertama success (200) dan kedua juga mengembalikan data yang identik tanpa status 500/409.
  3. Verifikasi hanya satu baris tersimpan pada tabel transactions dengan idempotency_key=12345 dan status completed.
  4. Periksa log trace: ada dua record request tetapi nilai trace_id sama bila middleware mengikat, dan tidak ada entri duplicate transaction insert.

Dengan skenario ini tim QA bisa memastikan perbaikan tidak merusak flow biasa dan memotong retry loop.

Kesimpulan

Bug transaksi ganda akibat retry API mudah dikenali dari log duplicate dan HTTP 409, dan paling efektif diatasi dengan kombinasi request tracing, idempotent key, locking database, dan observabilitas yang baik. Pendekatan ini memastikan backend CodeIgniter 4 tetap aman walau client melakukan retry otomatis.