# Idempotency Key: Mendesain API yang Tahan Retry

> Pembayaran terpotong dua kali selalu berawal dari timeout dan retry. Cara mendesain endpoint idempotent dengan idempotency key, dari skema storage sampai perilaku client.

URL: https://www.matthewswong.com/id/blog/idempotency-keys-api-design/

- Author: Matthews Wong (https://www.matthewswong.com/id/about/)
- Published: 2026-04-16
- Updated: 2026-04-16
- Category: Backend

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.

## Kenapa Retry Itu Keniscayaan, Bukan Pengecualian

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.

## HTTP Sudah Menyelesaikan Setengahnya

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.

## Mekanika Sisi Server, Langkah demi Langkah

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:

Saat request tiba, klaim kuncinya secara atomik: insert baris dengan status in_progress memakai insert-on-conflict-do-nothing sehingga tepat satu request konkuren yang memenangkan klaim.

Kalau insert menang, jalankan handler sungguhan di dalam transaksi database yang sama dengan penulisan bisnis Anda bila memungkinkan, lalu simpan kode dan body respons di baris kunci itu dan tandai done.

Kalau kunci sudah ada dan berstatus done, bandingkan hash request tersimpan dengan request yang masuk. Hash sama: putar ulang respons tersimpan apa adanya. Hash beda: tolak dengan 422, karena client memakai ulang kunci untuk operasi berbeda, yang selalu merupakan bug client yang layak dibunyikan keras-keras.

Kalau kunci ada tapi masih in_progress, kembalikan 409 dengan petunjuk retry-after. Jangan blokir koneksi sambil menunggu; request asli bisa makan waktu beberapa detik, dan menyandera worker mengubah badai retry menjadi outage.

Race 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.

## Pelajaran dari Implementasi Stripe

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.

## Empat Jebakan yang Sampai ke Production

## 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.

## Kontrak Client: Satu Kunci per Operasi, Bukan per Request

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:

> 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.

## Checklist Implementasi

Terima header Idempotency-Key di setiap POST yang tidak safe; tolak request tanpa header itu di route kritis.

Klaim kunci secara atomik dengan satu statement insert-on-conflict; jangan pernah cek-lalu-insert.

Simpan hash request, kode respons, dan body respons; putar ulang error sama setianya dengan sukses.

Kembalikan 409 untuk retry konkuren yang masih berjalan dan 422 untuk pemakaian ulang kunci dengan payload berbeda.

Kedaluwarsakan kunci terjadwal, dokumentasikan jendelanya, dan kirim header penanda replay supaya client bisa mengamati deduplikasi bekerja.

## Desain untuk Retry, Karena Ia Pasti Datang

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.

## Sumber dan bacaan lanjutan

- Blog Stripe — Designing robust and predictable APIs with idempotency
- Referensi API Stripe — Idempotent requests
- Draft IETF — The Idempotency-Key HTTP Header Field

## Source links

- https://stripe.com/blog/idempotency
- https://docs.stripe.com/api/idempotent_requests
- https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/

## FAQ

### Mengapa tidak cukup hanya mengecek apakah request sudah diproses sebelum mengeksekusi ulang?

Pendekatan cek-lalu-insert adalah bug time-of-check ke time-of-use (TOCTOU): dua retry simultan bisa sama-sama lolos pengecekan dan keduanya mengeksekusi. Klaim kunci harus berupa satu statement atomik — insert dengan penanganan on-conflict — sehingga tepat satu request konkuren yang menang dan yang lain ditangkis dengan aman.

### Status HTTP apa yang harus dikembalikan server untuk request dengan idempotency key yang duplikat atau konflik?

Jika kunci ada dan masih berstatus in_progress (retry konkuren sudah berjalan), kembalikan 409 dengan petunjuk retry-after tanpa memblokir koneksi. Jika kunci ada tetapi hash body request yang masuk berbeda dari yang tersimpan, kembalikan 422, karena memakai ulang kunci untuk operasi berbeda selalu merupakan bug client yang layak dibunyikan keras-keras.

### Apakah respons error harus diputar ulang sama seperti respons sukses?

Ya. Perilaku production Stripe — target kalibrasi yang berguna — memutar ulang hasil asli baik sukses maupun error. Jika percobaan pertama gagal validasi dan mengembalikan 4xx, setiap retry mendapat 4xx yang sama alih-alih eksekusi kedua. Ini menjaga kontrak tetap dapat diprediksi dan mencegah badai retry menyembunyikan akar penyebab kegagalan.

### Kapan client harus membuat idempotency key baru versus memakai ulang yang lama?

Buat satu kunci saat pengguna memulai operasi dan pakai ulang kunci yang persis sama di setiap retry operasi tersebut. Membuat kunci baru per percobaan HTTP mengalahkan seluruh mekanismenya. Dalam praktiknya, kunci sebaiknya dibuat saat pengguna mengonfirmasi aksi — misalnya saat modal konfirmasi terbuka — sehingga semua retry jaringan berbagi kunci yang sama.

### Apa jebakan production yang paling umum dalam implementasi idempotency key?

Empat jebakan paling sering sampai ke production: scope kunci secara global alih-alih per endpoint dan per akun (membuka celah replay respons lintas tenant); tidak menyimpan hash request (diam-diam mengembalikan respons basi saat client memakai ulang kunci untuk payload berbeda); membiarkan tabel kunci tumbuh tanpa job TTL; dan menempatkan side effect eksternal seperti pengiriman email di luar transaksi database sehingga terulang saat retry.

---

- HTML version: https://www.matthewswong.com/id/blog/idempotency-keys-api-design/
- English: https://www.matthewswong.com/en/blog/idempotency-keys-api-design/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
