Jaringan komputer tidak pernah dapat diandalkan sepenuhnya: klien mengirim permintaan POST /payments, timeout terjadi, dan klien tidak tahu apakah pembayaran sudah diproses atau belum. Klien yang hati-hati akan mencoba ulang — dan di situlah bencana kecil terjadi: kartu pelanggan terpotong dua kali untuk pesanan yang sama. Idempotency adalah sifat operasi yang aman dijalankan berulang kali dengan hasil akhir yang sama, dan pada chapter ini kita akan membangun pola Idempotency-Key yang menjadikan endpoint POST kita tahan terhadap percobaan ulang semacam itu.

Apa itu Idempotency #

Sebuah operasi disebut idempotent bila menjalankannya sekali atau sepuluh kali menghasilkan keadaan akhir yang sama. Mengatur nama produk menjadi “Kopi Gayo” (PUT) tetap menghasilkan nama yang sama betapapun sering diulang; menghapus produk dengan DELETE membuat produk itu hilang, dan pengulangan penghapusan tidak mengubah apa pun. Sebaliknya, POST /payments yang menambah transaksi baru setiap dipanggil jelas tidak idempotent — setiap panggilan menambah satu baris pembayaran baru.

HTTP membedakan method idempotent dan non-idempotent secara eksplisit. Method idempotent sudah aman diulang oleh infrastruktur (proxy, load balancer, SDK) tanpa persetujuan kita; method non-idempotent membutuhkan perlindungan tambahan bila klien berencana mencoba ulang:

MethodIdempotentAlasan
GETYaHanya membaca, tidak mengubah keadaan
HEAD, OPTIONSYaSama seperti GET tanpa body
PUTYaMenimpa nilai dengan data yang sama, hasil akhir identik
DELETEYaPenghapusan kedua atas resource yang sudah hilang tidak berefek
POSTTidakSetiap panggilan biasanya menciptakan resource baru
PATCHTidakPatch bertahap (misal “tambah 1”) menumpuk efeknya

Kita dapat memetakan tabel di atas ke dalam kode sederhana:

php
<?php

const IDEMPOTENT_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'];

function isIdempotent(string $method): bool
{
    return in_array(strtoupper($method), IDEMPOTENT_METHODS, true);
}

foreach (['GET', 'PUT', 'DELETE', 'POST', 'PATCH'] as $method) {
    printf("%-6s : %s\n", $method, isIdempotent($method) ? 'idempotent' : 'TIDAK idempotent');
}

Output yang Dihasilkan:

text
GET    : idempotent
PUT    : idempotent
DELETE : idempotent
POST   : TIDAK idempotent
PATCH  : TIDAK idempotent

Fungsi isIdempotent() memeriksa keanggotaan method (setelah dinormalisasi ke huruf kapital) di daftar IDEMPOTENT_METHODS. Daftar ini nanti berguna di middleware: permintaan dengan method idempotent boleh langsung diteruskan, sedangkan POST dan PATCH perlu diperiksa lebih dulu apakah membawa kunci idempotency. Perlu dicatat bahwa PATCH secara teknis bisa dibuat idempotent bila isinya absolut (misal {"harga": 85000}), tetapi karena tidak dijamin, praktik aman memperlakukannya seperti POST.

Masalah Pembayaran Ganda #

Mari kita wujudkan masalahnya dengan handler pembayaran yang belum dilindungi apa pun. Untuk membuat contoh dapat diuji secara deterministik, transaction_id diturunkan dari hash() terhadap ID pesanan — bukan dari angka acak:

php
<?php

function chargeCreditCard(string $orderId, int $amountCents): array
{
    // ... proses kartu di penyedia pembayaran ...
    return [
        'transaction_id' => 'TRX-' . strtoupper(substr(hash('sha256', $orderId), 0, 8)),
        'order_id'       => $orderId,
        'amount_cents'   => $amountCents,
        'status'         => 'settled',
    ];
}

$laporan = [];
$laporan[] = chargeCreditCard('ORD-2026-0001', 250000);
$laporan[] = chargeCreditCard('ORD-2026-0001', 250000);

echo "jumlah transaksi     : " . count($laporan) . "\n";
echo "transaction_id pertama : {$laporan[0]['transaction_id']}\n";
echo "transaction_id kedua   : {$laporan[1]['transaction_id']}\n";
echo "total tercatat         : Rp" . array_sum(array_column($laporan, 'amount_cents')) . "\n";

Output yang Dihasilkan:

text
jumlah transaksi     : 2
transaction_id pertama : TRX-39CDE477
transaction_id kedua   : TRX-39CDE477
total tercatat         : Rp500000

Handler chargeCreditCard() sendiri tidak tahu bahwa dirinya dipanggil dua kali untuk pesanan ORD-2026-0001 — dari sudut pandang handler, kedua panggilan adalah permintaan sah yang berbeda. Output memperlihatkan akibatnya: tercatat dua transaksi dengan total Rp500.000, padahal niat pelanggan hanya membayar sekali. Skenario seperti ini nyata: SDK pembayaran mencoba ulang otomatis saat timeout, pengguna mengklik tombol “Bayar” dua kali karena halaman lambat, atau proxy mengulang permintaan yang dianggap gagal.

Pola Idempotency-Key #

Pola Idempotency-Key memberi klien tanggung jawab menamai operasinya: klien mengirim header Idempotency-Key: <nilai unik> pada permintaan pertama, dan memakai nilai yang sama pada setiap percobaan ulang. Server menyimpan pasangan kunci → respons saat operasi pertama kali dijalankan; ketika permintaan dengan kunci yang sama datang lagi, server cukup memutar ulang respons tersimpan tanpa menjalankan operasi. Contoh permintaan klien:

bash
curl -i -X POST https://api.example.com/v1/payments \
  -H "Idempotency-Key: 6f9c2a10-9f6e-4f0a-8b21-2c9d4e7a1b55" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "ORD-2026-0001", "amount_cents": 250000}'

Kita simpan pemetaan kunci → respons di tabel SQLite (di sini :memory: agar contoh berdiri sendiri; di produksi memakai database atau Redis yang sama tahan lama dengan operasinya). Seluruh keputusan diambil oleh fungsi murni executeIdempotent() yang menerima waktu $now dari luar, sehingga skenario kedaluwarsa pun dapat diuji secara deterministik:

php
<?php

function createIdempotencyDb(): PDO
{
    $db = new PDO('sqlite::memory:');
    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $db->exec('CREATE TABLE idempotency_records (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        idem_key TEXT NOT NULL,
        endpoint TEXT NOT NULL,
        status INTEGER NOT NULL,
        body TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        expires_at INTEGER NOT NULL,
        UNIQUE (idem_key, endpoint)
    )');

    return $db;
}

function executeIdempotent(
    PDO $db,
    string $idemKey,
    string $endpoint,
    callable $operation,
    int $now,
    int $ttlSeconds
): array {
    $stmt = $db->prepare(
        'SELECT status, body, expires_at FROM idempotency_records
         WHERE idem_key = ? AND endpoint = ?'
    );
    $stmt->execute([$idemKey, $endpoint]);
    $saved = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($saved !== false) {
        if ((int) $saved['expires_at'] > $now) {
            return [
                'status' => (int) $saved['status'],
                'body'   => json_decode($saved['body'], true),
                'cached' => true,
            ];
        }
        $db->prepare('DELETE FROM idempotency_records WHERE idem_key = ? AND endpoint = ?')
           ->execute([$idemKey, $endpoint]);
    }

    $result = $operation();
    $db->prepare(
        'INSERT INTO idempotency_records
         (idem_key, endpoint, status, body, created_at, expires_at)
         VALUES (?, ?, ?, ?, ?, ?)'
    )->execute([
        $idemKey,
        $endpoint,
        $result['status'],
        json_encode($result['body'], JSON_UNESCAPED_UNICODE),
        $now,
        $now + $ttlSeconds,
    ]);

    return ['status' => $result['status'], 'body' => $result['body'], 'cached' => false];
}

$db = createIdempotencyDb();
$eksekusi = 0;

$bayar = function () use (&$eksekusi): array {
    $eksekusi++;

    return [
        'status' => 201,
        'body'   => [
            'transaction_id' => 'TRX-' . strtoupper(substr(hash('sha256', 'ORD-2026-0001'), 0, 8)),
            'status'         => 'settled',
        ],
    ];
};

$now = 1730000000;
$ttl = 86400;

$percobaan = [
    ['POST /payments', $now],
    ['POST /payments', $now + 5],
    ['POST /refunds', $now + 10],
    ['POST /payments', $now + $ttl + 10],
];

foreach ($percobaan as [$endpoint, $waktu]) {
    $hasil = executeIdempotent($db, 'key-order-0001', $endpoint, $bayar, $waktu, $ttl);
    printf(
        "%-14s t=%d -> HTTP %d, transaction_id=%s, dari_cache=%s\n",
        $endpoint,
        $waktu,
        $hasil['status'],
        $hasil['body']['transaction_id'],
        $hasil['cached'] ? 'ya' : 'tidak'
    );
}
echo "operasi benar-benar dieksekusi: {$eksekusi} kali\n";

Output yang Dihasilkan:

text
POST /payments t=1730000000 -> HTTP 201, transaction_id=TRX-39CDE477, dari_cache=tidak
POST /payments t=1730000005 -> HTTP 201, transaction_id=TRX-39CDE477, dari_cache=ya
POST /refunds  t=1730000010 -> HTTP 201, transaction_id=TRX-39CDE477, dari_cache=tidak
POST /payments t=1730086410 -> HTTP 201, transaction_id=TRX-39CDE477, dari_cache=tidak
operasi benar-benar dieksekusi: 3 kali

Mari kita bedah alur fungsinya baris demi baris:

  1. SELECT ... WHERE idem_key = ? AND endpoint = ?: mencari respons tersimpan untuk kombinasi kunci dan endpoint; kedua kolom dilindungi constraint UNIQUE agar tidak pernah ada dua baris untuk kombinasi yang sama.
  2. Blok if ($saved !== false): bila rekaman ditemukan dan expires_at masih di masa depan, respons tersimpan langsung dikembalikan dengan penanda cached => true — operasi $operation() tidak pernah tersentuh.
  3. DELETE untuk rekaman kedaluwarsa: kunci lama dibersihkan sebelum kunci baru ditulis, sehingga INSERT berikutnya tidak melanggar constraint UNIQUE.
  4. $result = $operation();: inilah satu-satunya titik eksekusi nyata (pemotongan kartu, penulisan transaksi). Setelah itu hasilnya disimpan lengkap dengan status code dan body JSON.
  5. Closure $bayar dengan &$eksekusi: penghitung ini membuktikan di output berapa kali operasi benar-benar berjalan.

Output membuktikan pola ini bekerja: percobaan kedua di POST /payments menerima respons yang sama persis dari cache (dari_cache=ya), kunci yang sama pada endpoint POST /refunds dianggap operasi berbeda, dan setelah TTL 86.400 detik berlalu kunci kedaluwarsa sehingga operasi dieksekusi ulang — total hanya 3 eksekusi nyata, bukan 4.

Di sisi server, klien mengirim header sebagai HTTP_IDEMPOTENCY_KEY di superglobal $_SERVER. Fungsi kecil berikut mengekstraksi dan membersihkannya, dengan array $server tiruan agar tetap teruji tanpa server web:

php
<?php

function extractIdempotencyKey(array $server): ?string
{
    $key = $server['HTTP_IDEMPOTENCY_KEY'] ?? null;
    if (!is_string($key) || trim($key) === '') {
        return null;
    }

    return trim($key);
}

var_dump(extractIdempotencyKey(['HTTP_IDEMPOTENCY_KEY' => '  key-order-0001  ']));
var_dump(extractIdempotencyKey(['REQUEST_METHOD' => 'POST']));

Output yang Dihasilkan:

text
string(14) "key-order-0001"
NULL

Fungsi extractIdempotencyKey() mengembalikan null ketika header tidak ada atau hanya berisi spasi, dan mengembalikan nilai yang sudah dipangkas (trim) ketika valid. Handler dapat menolak permintaan POST tanpa kunci dengan status 400, atau — pilihan yang lebih ramah untuk API publik — menerimanya tanpa perlindungan idempotency dan mendokumentasikan perilaku tersebut secara eksplisit.

Kedaluwarsa dan Scope Kunci #

Rekaman idempotency tidak boleh hidup selamanya: penyimpanan akan membengkak, dan klien yang menyalin nilai kunci lama bisa saja “mewarisi” respons yang tidak relevan. TTL 24 jam adalah pilihan umum untuk pembayaran — cukup panjang untuk menutup semua percobaan ulang wajar (retry dengan exponential backoff biasanya selesai dalam hitungan menit), cukup pendek untuk menjaga tabel tetap ramping. Pada contoh kita, expires_at = created_at + ttl dibandingkan terhadap parameter $now, dan baris kedaluwarsa dihapus sebelum diganti.

Scope kunci juga harus diperbatas ke satu endpoint. Kunci key-order-0001 pada POST /payments dan POST /refunds adalah dua operasi bisnis yang berbeda; bila keduanya berbagi rekaman, permintaan refund yang sah akan menerima respons pembayaran yang tersimpan. Kolom endpoint pada constraint UNIQUE (idem_key, endpoint) menegakkan aturan ini di level skema. API yang lebih ketat juga mengikat kunci ke fingerprint body permintaan sehingga kunci yang sama dengan isi berbeda ditolak dengan status 422.

Referensi #

Ringkasan #

Idempotency membuat operasi aman diulang: method GET, PUT, dan DELETE memilikinya secara alami, sementara POST membutuhkan pola Idempotency-Key — klien menamai operasinya, server menyimpan respons pertama, dan setiap percobaan ulang menerima respons tersimpan tersebut tanpa efek samping tambahan. Rekaman kunci berumur terbatas (TTL) dan terikat scope endpoint agar tidak membengkak maupun tertukar. Pembatasan laju dan idempotency sama-sama melindungi API dari perilaku klien; untuk memastikan keduanya (dan seluruh handler Anda) benar-benar bekerja, chapter berikutnya membahas strategi pengujian API secara berlapis.