Idempotency Key: Mendesain API yang Tahan Retry

Foto oleh Karola G (Kaboompics)

Foto oleh Karola G (Kaboompics)
Setiap pelanggan yang tertagih dua kali dalam sejarah punya cerita asal yang sama: client mengirim request pembayaran, jaringan timeout sebelum respons tiba, lalu seseorang atau sesuatu melakukan retry. Server memproses keduanya. Tidak ada yang menulis bug dalam arti biasa; semua pihak berperilaku wajar. Sistem secara keseluruhan tetap menarik uang dua kali, dan sekarang tim support memproses refund sementara engineering menulis postmortem.
Obatnya sudah jadi standar industri selama satu dekade: idempotency key. Client melampirkan kunci unik pada tiap operasi logis, dan server menjamin kunci tersebut dieksekusi paling banyak sekali, memutar ulang respons asli untuk setiap retry. Stripe mempopulerkan polanya, working group HTTP API di IETF menyusun draft header standarnya, dan setiap sistem yang bersinggungan dengan pembayaran yang saya bangun sejak itu memakainya. Tulisan ini membahas desain lengkapnya: semantik HTTP, storage di server, konkurensi, dan kontrak client.
Masalah fundamentalnya adalah kegagalan jaringan itu ambigu. Saat panggilan HTTP Anda timeout, Anda tidak bisa tahu apakah request tidak pernah sampai, sampai lalu gagal, atau sampai dan berhasil tapi responsnya hilang. Blog engineering Stripe menyebut ini masalah dua jenderal klasik dalam wujud API: client harus memilih antara tidak pernah retry, dengan risiko operasi hilang, atau retry, dengan risiko duplikat.
Praktiknya Anda bahkan tidak bisa memilih. Jaringan seluler di Indonesia memutus koneksi di tengah jalan terus-menerus; load balancer melakukan retry saat idle timeout; consumer queue mengirim ulang dengan semantik at-least-once; pengguna yang tidak sabar menekan tombol lagi. Endpoint POST mana pun yang memindahkan uang, membuat order, atau mengirim pesan cepat atau lambat akan dipanggil dua kali dengan maksud yang sama. Satu-satunya pertanyaan adalah apakah API Anda didesain untuk menyadarinya.
Idempotensi sudah tertanam di sebagian besar method HTTP berdasarkan definisinya; celahnya ada di POST. Celah itu persis tempat idempotency key hidup:
| Method | Idempotent menurut spek? | Artinya dalam praktik |
|---|---|---|
| GET / HEAD | Ya (juga safe) | Murni baca. Retry sebebasnya. Stripe secara eksplisit tidak menerima idempotency key di sini karena tidak akan menambah apa pun. |
| PUT | Ya | Penggantian penuh sebuah resource. Mengirim PUT yang sama dua kali konvergen ke state yang sama. Desain update sebagai PUT bila memungkinkan dan Anda dapat keamanan retry gratis. |
| DELETE | Ya | Delete kedua mengembalikan 404 alih-alih 200; state-nya identik. Perlakukan 404 setelah retry delete sebagai sukses di client. |
| POST | Tidak | Membuat resource baru atau memicu aksi setiap kali dipanggil. Di sinilah tagihan ganda hidup, dan di sinilah header Idempotency-Key membayar sewanya. |
Implementasi yang kokoh adalah state machine kecil yang dikunci oleh idempotency key milik client. Empat langkah di bawah, beserta skemanya, adalah bentuk yang saya deploy di NestJS di atas PostgreSQL:
-- The storage that makes it work: one row per key attempt
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL, -- sha256 of method+path+body
response_code int,
response_body jsonb,
status text NOT NULL DEFAULT 'in_progress',
created_at timestamptz NOT NULL DEFAULT now()
);
-- NestJS guard sketch (the real one is an interceptor):
-- 1. INSERT ... ON CONFLICT (key) DO NOTHING RETURNING *
-- 2. inserted? → run the handler, store response, status='done'
-- 3. conflict + done? → compare request_hash:
-- same → replay stored response (200, same body)
-- differs → 422: key reused for a different request
-- 4. conflict + in_progress? → 409: original still running, retry laterRace antara dua retry simultan adalah bagian yang paling sering salah di implementasi buatan sendiri. Mengecek keberadaan dengan select lalu insert adalah bug time-of-check ke time-of-use: kedua request lolos pengecekan dan keduanya mengeksekusi. Klaim harus berupa satu statement atomik, entah insert dengan penanganan on-conflict atau advisory lock. Ini juga alasan implementasi Redis-saja butuh kehati-hatian: Anda ingin klaim kunci dan penulisan bisnis commit atau gagal bersama-sama, yang diberikan transaksi relasional secara gratis.
Perilaku production Stripe adalah target kalibrasi yang berguna karena sudah selamat dari trafik retry lebih banyak daripada sistem mana pun yang akan kita bangun. Kunci diterima di semua request POST, tidak diterima di GET dan DELETE karena keduanya sudah idempotent, dan disimpan setidaknya 24 jam, setelah itu kunci yang dipakai ulang diperlakukan sebagai baru. Respons yang diputar ulang mengembalikan hasil asli baik sukses maupun error, dengan header penanda idempotent-replayed supaya client bisa membedakannya.
Dua pilihan desain menonjol. Pertama, error ikut diputar ulang: kalau percobaan asli gagal validasi, retry mendapat 4xx yang sama alih-alih eksekusi kedua. Kedua, kuncinya hanyalah string opaque sampai 255 karakter, dengan rekomendasi UUID; server tidak menempelkan makna apa pun pada isinya. Kedua pilihan menjaga kontraknya cukup sederhana sehingga semua library client bisa mengimplementasikannya secara identik.
Scope kunci terlalu luas
Scope kunci per endpoint dan per akun terautentikasi, bukan global. Kalau tidak, retry tenant A bisa bertabrakan dengan kunci tenant B dan memutar ulang respons milik orang lain. Komposit kunci storage dari akun, route, dan kunci client.
Hash request tidak disimpan
Tanpa hash body request, bug client yang memakai ulang kunci akan diam-diam mengembalikan respons basi untuk operasi baru. Simpan hash dari method, path, dan body, dan gagalkan dengan keras saat tidak cocok.
Tabel kunci tanpa batas
Kunci adalah data operasional, bukan sejarah. Tanpa job TTL tabel Anda tumbuh selamanya dan unique index memperlambat setiap klaim. Ikuti preseden Stripe: bersihkan setelah 24 jam, atau berapa pun yang cocok dengan jendela retry realistis terpanjang client Anda.
Side effect diputar ulang di luar transaksi
Kalau handler Anda mengirim email lalu crash sebelum menandai kunci done, retry akan mengirim email lagi. Jauhkan side effect eksternal dari bagian idempotent: enqueue secara transaksional dan biarkan worker queue yang deduplikasi.
Desain server hanya berfungsi kalau client memegang separuh kontraknya: hasilkan satu kunci saat pengguna memulai operasi, lalu pakai ulang kunci yang persis sama di setiap retry operasi itu. Kunci baru per percobaan HTTP mengalahkan seluruh mekanismenya. Di frontend ERP kami kunci dibuat saat modal konfirmasi terbuka, sehingga tombol approve boleh digedor, jaringan boleh naik-turun, dan backend tetap membukukan tepat satu jurnal:
// Client side: generate ONE key per logical operation,
// reuse it across every retry of that operation.
const idempotencyKey = crypto.randomUUID()
async function createPayment(payload: PaymentDto) {
return retryWithBackoff(() =>
fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // same key on every attempt
},
body: JSON.stringify(payload),
})
)
}Jadikan idempotency key wajib, bukan opsional, di setiap POST yang memindahkan uang. Kunci opsional berarti satu client yang lupa adalah yang menagih dua kali. Di NestJS ini guard lima baris: tolak POST ke route pembayaran yang tidak membawa header-nya, dan kontraknya jadi mustahil diabaikan di integration test.
Idempotency key termasuk pola dengan rasio nilai-terhadap-kode yang luar biasa: satu tabel, satu interceptor, dan satu header mengubah kelas bug distributed-systems paling menakutkan menjadi non-kejadian. Intinya, perlakukan retry sebagai kepastian beroperasi di jaringan nyata, apalagi di koneksi seluler yang dipakai mayoritas pengguna Indonesia, dan jadikan eksekusi at-most-once sebagai properti yang dijamin API Anda, bukan perilaku yang Anda harapkan. Bangun sekali, pasang di setiap POST yang penting, dan postmortem tagihan ganda menjadi cerita orang lain.