Kontrak API yang tidak rapuh berarti klien dan server tetap bisa mencapai hasil yang benar walau jaringan tidak andal, request terduplikasi, atau respons datang terlambat. Masalahnya bukan sekadar "retry atau tidak", melainkan apakah kontrak API Anda menjelaskan perilaku sistem secara tegas saat kondisi gagal terjadi.
Banyak integrasi menjadi makin kompleks bukan karena bisnisnya rumit, tetapi karena kontraknya membiarkan terlalu banyak asumsi implisit. Klien menebak apakah request aman diulang, server tidak membedakan error validasi dan konflik, webhook datang tanpa urutan yang jelas, lalu semua pihak menambal bug dengan retry tambahan. Hasilnya adalah “menara kompleksitas” yang terus naik. Solusinya bukan menambah logika acak di sisi klien, melainkan memperjelas kontrak dari awal.
Mengapa retry, timeout, dan duplikasi merusak integrasi
Dalam sistem nyata, beberapa hal berikut hampir pasti terjadi:
- Timeout di sisi klien: server mungkin sudah memproses request, tetapi respons tidak sempat diterima.
- Retry otomatis: load balancer, SDK, job queue, atau klien sendiri mengirim ulang request yang sama.
- Request ganda: pengguna menekan tombol dua kali, worker berjalan ulang, atau ada race condition.
- Webhook terlambat atau tidak berurutan: event status lama bisa tiba setelah status baru.
Jika kontrak API tidak mendefinisikan perilaku untuk kasus-kasus itu, klien akan menebak. Tebakan yang berbeda antara tim adalah sumber utama bug integrasi.
Prinsip dasar kontrak API yang tahan gagal
1. Operasi harus punya identitas, bukan hanya payload
Jika klien ingin mengatakan “ini permintaan bisnis yang sama”, server membutuhkan pengenal yang stabil. Di sinilah idempotency key dipakai. Tanpa itu, dua request identik secara isi bisa dianggap dua operasi berbeda.
2. Status HTTP harus mencerminkan hasil semantik
Klien perlu bisa membedakan:
- berhasil sinkron sekarang,
- diterima untuk diproses nanti,
- konflik dengan state yang sudah ada,
- atau payload valid secara bentuk tetapi tidak valid secara bisnis.
Jika semuanya dibungkus sebagai 200 atau 400, retry dan penanganan error akan salah.
3. Respons harus cukup informatif untuk rekonsiliasi
Saat timeout terjadi, klien butuh cara untuk mengetahui apakah operasi sudah dijalankan. Artinya, API perlu menyediakan identifier resource, status pemrosesan, dan referensi operasi yang bisa dicari ulang.
4. Event asinkron harus bisa dideduplikasi dan diurutkan
Webhook tidak boleh diasumsikan datang sekali dan selalu berurutan. Kontrak perlu menyatakan apakah event minimal sekali kirim, bagaimana deduplikasinya, dan field mana yang dipakai untuk ordering.
Idempotency key: inti kontrak untuk operasi yang bisa diulang
Idempotency key adalah nilai unik yang dikirim klien untuk menandai satu operasi logis, misalnya pembuatan pembayaran atau order. Jika request yang sama dikirim ulang dengan key yang sama, server seharusnya tidak mengeksekusi efek samping dua kali.
Kapan wajib dipakai
- POST yang membuat resource atau memicu efek samping eksternal.
- Operasi yang bisa terkena timeout lalu di-retry.
- Aksi yang tidak aman diduplikasi, seperti charge kartu, kirim email, buat invoice, atau reservasi stok.
Contoh request
POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: pay_01JX8N8C0QJ6Y7K2P4M3R1S9AB
{
"order_id": "ord_10291",
"amount": 150000,
"currency": "IDR",
"customer_id": "cus_7781"
}Contoh respons pertama: berhasil diproses sinkron
HTTP/1.1 200 OK
Content-Type: application/json
{
"payment_id": "pay_78421",
"status": "succeeded",
"order_id": "ord_10291",
"amount": 150000,
"currency": "IDR"
}Contoh respons retry dengan key yang sama
HTTP/1.1 200 OK
Content-Type: application/json
{
"payment_id": "pay_78421",
"status": "succeeded",
"order_id": "ord_10291",
"amount": 150000,
"currency": "IDR"
}Perilaku penting di sini: respons retry mengembalikan hasil operasi yang sama, bukan membuat objek baru.
Trade-off implementasi
- Penyimpanan key: Anda perlu menyimpan pasangan idempotency_key - hasil operasi. Ini menambah biaya storage dan kompleksitas cleanup.
- Masa berlaku: key tidak bisa disimpan selamanya. TTL perlu didokumentasikan, misalnya 24 jam atau 7 hari, tergantung risiko retry terlambat.
- Scope key: key sebaiknya unik dalam konteks tertentu, misalnya per merchant atau per account, agar tidak terjadi tabrakan lintas tenant.
- Payload mismatch: jika key yang sama dipakai ulang dengan payload berbeda, server harus menolak dengan jelas. Diam-diam menerima ini akan menghasilkan bug serius.
Respons saat key sama tetapi payload berbeda
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": {
"code": "idempotency_key_conflict",
"message": "Idempotency-Key sudah pernah dipakai dengan payload berbeda.",
"retryable": false
}
}Memilih 409 Conflict masuk akal karena masalahnya bukan format payload, melainkan benturan dengan operasi yang sudah tercatat.
Kapan memakai 200, 202, 409, dan 422
Status code bukan kosmetik. Ia adalah sinyal kontrak yang menentukan perilaku klien.
200 OK
Pakai 200 ketika hasil akhir operasi sudah diketahui dan respons merepresentasikan status final atau state resource yang stabil.
- Contoh: pembayaran berhasil diproses sinkron.
- Retry dengan idempotency key yang sama juga bisa tetap mengembalikan
200dengan body yang sama secara semantik.
202 Accepted
Pakai 202 ketika request valid dan diterima, tetapi pemrosesan final berlangsung asinkron. Ini penting agar klien tidak mengira operasi sudah selesai.
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"operation_id": "op_99102",
"status": "pending",
"payment_id": "pay_78421",
"poll_url": "/operations/op_99102"
}Jika Anda memakai 202, kontrak harus menjelaskan cara memeriksa hasil akhir: polling endpoint, webhook, atau keduanya.
409 Conflict
Pakai 409 saat request berbenturan dengan state yang sudah ada.
- Idempotency key yang sama dengan payload berbeda.
- Mencoba membuat resource dengan natural key yang sudah terpakai.
- Transisi state tidak sah karena resource sudah berubah.
409 memberi sinyal bahwa klien perlu rekonsiliasi state, bukan sekadar memperbaiki format request.
422 Unprocessable Content
Pakai 422 ketika payload lolos parsing dan schema dasar, tetapi gagal aturan bisnis atau validasi domain.
- Jumlah pembayaran negatif.
- Mata uang tidak didukung untuk merchant tersebut.
- Status transisi tidak diizinkan oleh aturan domain, jika masalah utamanya ada di isi perintah, bukan race dengan state aktual.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
{
"error": {
"code": "validation_failed",
"message": "Request tidak valid secara bisnis.",
"details": [
{
"field": "currency",
"reason": "unsupported_for_merchant"
}
],
"retryable": false
}
}Aturan praktis: 200 untuk hasil final yang sudah diketahui, 202 untuk diterima tapi belum selesai, 409 untuk konflik dengan state yang ada, dan 422 untuk isi request yang secara semantik tidak bisa diproses.
Model error yang membantu klien memutuskan retry
Salah satu sumber kontrak rapuh adalah error model yang terlalu umum: semua error hanya berisi string pesan. Klien akhirnya melakukan parsing teks atau menebak-nebak apakah aman untuk retry.
Minimal, error sebaiknya memiliki:
- code: kode stabil untuk automasi.
- message: penjelasan untuk manusia.
- retryable: apakah request yang sama layak dicoba lagi.
- details: field-level error atau metadata tambahan.
- request_id: untuk debugging lintas sistem.
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
{
"error": {
"code": "upstream_timeout",
"message": "Layanan pemrosesan pembayaran tidak merespons tepat waktu.",
"retryable": true,
"details": [],
"request_id": "req_01JX8NG3R9B2T4"
}
}Trade-off: field retryable sangat membantu klien, tetapi jangan menjadikannya satu-satunya sinyal. Klien tetap perlu melihat konteks operasi, karena retry yang teknisnya mungkin bisa dilakukan belum tentu aman tanpa idempotency key.
Deduplikasi di server: jangan hanya bergantung pada klien
Idempotency key adalah kontrak di level API, tetapi deduplikasi juga perlu dijaga di lapisan penyimpanan dan pemrosesan. Alasannya sederhana: race condition tetap bisa terjadi walau klien “berperilaku baik”.
Lapisan yang umum dipakai
- Unique constraint di database untuk natural key atau pasangan tenant + external_id.
- Tabel idempotency yang menyimpan key, hash payload, status, dan hasil respons.
- Outbox/inbox table untuk event dan webhook agar konsumsi bisa dideduplikasi.
- Lock singkat hanya bila benar-benar perlu, karena lock mudah menjadi bottleneck.
Skema data yang berguna
idempotency_records
- tenant_id
- idempotency_key
- request_hash
- operation_status -- pending | completed | failed
- response_code
- response_body
- resource_id
- created_at
- expires_atrequest_hash berguna untuk mendeteksi pemakaian ulang key dengan payload berbeda. operation_status membantu saat request kedua datang ketika operasi pertama masih berjalan.
Kasus penting: request kedua datang saat operasi pertama belum selesai
Anda punya beberapa pilihan:
- Kembalikan 202 dengan status pending yang sama. Cocok bila hasil akhir memang asinkron.
- Tahan sampai hasil final tersedia. Ini menyederhanakan klien, tetapi bisa memperburuk latency dan penggunaan koneksi.
- Kembalikan representasi “in progress” yang bisa dipolling. Ini sering paling eksplisit.
Yang penting adalah kontraknya konsisten dan didokumentasikan.
Webhook terlambat dan ordering event
Webhook hampir selalu punya jaminan pengiriman minimal-sekali, bukan tepat-sekali. Itu berarti penerima harus siap menghadapi duplikasi dan urutan yang tidak stabil.
Apa yang harus ada dalam event
- event_id unik untuk deduplikasi.
- event_type yang stabil.
- resource_id atau aggregate id.
- occurred_at atau versi/sequence yang mendukung ordering.
- resource_version bila memungkinkan.
{
"event_id": "evt_01JX8NVJ3M6S2A",
"event_type": "payment.updated",
"occurred_at": "2026-07-13T10:15:30Z",
"resource_id": "pay_78421",
"resource_version": 3,
"data": {
"payment_id": "pay_78421",
"status": "succeeded",
"order_id": "ord_10291"
}
}Strategi ordering yang paling praktis
Jangan mengandalkan timestamp saja untuk ordering ketat. Clock skew, retry, dan pipeline asinkron bisa membuat timestamp menipu. Jika satu resource memiliki perubahan status berurutan, resource_version atau sequence number biasanya lebih aman.
Penerima webhook dapat menyimpan versi terakhir per resource:
- Jika event baru memiliki versi lebih kecil atau sama dari yang sudah diproses, abaikan sebagai duplikat atau event lama.
- Jika versinya meloncat, putuskan apakah perlu fetch state terbaru dari API sumber.
Ini jauh lebih andal daripada mengasumsikan semua event akan datang berurutan.
Kapan fetch state lebih baik daripada percaya pada webhook
Untuk domain yang sensitif, webhook sebaiknya dianggap sebagai sinyal perubahan, bukan satu-satunya sumber kebenaran. Setelah menerima event, penerima bisa mengambil state terbaru via API GET. Trade-off-nya adalah tambahan trafik dan latensi, tetapi hasilnya sering lebih konsisten daripada memproses payload event secara buta.
Versi field dan evolusi kontrak
Kontrak API yang tahan lama juga harus bisa berubah tanpa merusak retry flow atau consumer lama. Masalah sering muncul bukan saat menambah field baru, tetapi saat mengubah makna field lama.
Praktik yang aman
- Tambahkan field baru secara backward-compatible daripada mengubah arti field lama.
- Jangan menghapus field mendadak; deprekasikan dan beri masa transisi.
- Bedakan versi skema dan versi resource. Versi skema untuk kontrak API, versi resource untuk ordering state.
- Dokumentasikan nullable vs absent. Banyak bug integrasi muncul karena klien tidak tahu apakah field null berarti "kosong", "belum dihitung", atau "tidak relevan".
Contoh anti-pattern field
- Field
statusawalnya berarti status internal, lalu diam-diam berubah menjadi status settlement eksternal. - Field
processed_atkadang berarti waktu accepted, kadang berarti waktu final success. - Menambah enum baru tanpa mendokumentasikan bahwa klien harus tahan terhadap nilai yang belum dikenal.
Untuk kontrak yang menghadapi retry dan event terlambat, arti field waktu dan status harus sangat tegas.
Dokumentasi perilaku retry: bagian yang sering dilupakan
Banyak dokumentasi API menjelaskan endpoint dan schema, tetapi tidak menjelaskan perilaku saat request diulang. Padahal ini inti dari integrasi yang andal.
Dokumentasi minimal yang seharusnya ada
- Endpoint mana yang mendukung Idempotency-Key.
- Format, scope, dan TTL key.
- Apa yang terjadi jika key sama dikirim dengan payload berbeda.
- Apakah retry mengembalikan respons final lama atau status operasi saat ini.
- Status code yang mungkin muncul pada request awal vs request duplikat.
- Apakah webhook dikirim minimal sekali, dan bagaimana deduplikasi dilakukan.
- Bagaimana ordering event dijamin, jika memang dijamin.
Jika bagian ini tidak eksplisit, klien akan membangun asumsi sendiri. Itulah awal kontrak rapuh.
Anti-pattern umum
1. Menganggap POST selalu tidak aman untuk retry
POST memang tidak idempoten secara default, tetapi bukan berarti tidak bisa dibuat aman untuk operasi tertentu. Menolak retry tanpa menyediakan idempotency key hanya memindahkan masalah ke klien.
2. Mengembalikan 200 untuk semua hasil
Ini membuat klien tidak bisa membedakan accepted, duplicated, conflict, dan validation error. Akhirnya parsing body menjadi satu-satunya mekanisme kontrol.
3. Idempotency key tanpa validasi payload
Jika key yang sama dengan payload berbeda tetap diterima, Anda berisiko mengaitkan hasil operasi lama ke intent baru. Ini lebih buruk daripada tidak punya idempotency sama sekali.
4. Mengandalkan timestamp webhook sebagai ordering tunggal
Timestamp membantu observabilitas, tetapi tidak cukup untuk ordering ketat. Gunakan versi resource atau sequence jika state machine penting.
5. Webhook dianggap tepat-sekali
Jika consumer tidak menyimpan event_id untuk deduplikasi, duplikasi pengiriman akan memicu side effect berulang.
6. Tidak ada endpoint rekonsiliasi
Saat timeout terjadi, klien membutuhkan cara untuk mencari status operasi atau state resource terbaru. Tanpa itu, retry menjadi tebakan.
Contoh alur desain yang lebih tahan gagal
- Klien mengirim
POST /paymentsdenganIdempotency-Key. - Server membuat atau menemukan record idempotency berdasarkan tenant + key.
- Jika key baru, server memulai operasi dan menandai status
pending. - Jika operasi selesai cepat, kembalikan
200dengan hasil final. - Jika butuh proses asinkron, kembalikan
202denganoperation_iddan cara polling. - Jika key yang sama dipakai dengan payload berbeda, kembalikan
409. - Jika request valid secara bentuk tetapi gagal aturan domain, kembalikan
422. - Saat status berubah, kirim webhook dengan
event_iddanresource_version. - Consumer webhook mendeduplikasi berdasarkan
event_iddan mengabaikan event dengan versi lama.
Alur ini tidak menghilangkan semua kompleksitas, tetapi memindahkannya ke tempat yang lebih bisa diprediksi: kontrak yang eksplisit.
Checklist desain kontrak API yang tidak rapuh
- Apakah operasi yang berisiko diduplikasi mendukung idempotency key?
- Apakah scope, TTL, dan aturan payload mismatch didokumentasikan?
- Apakah ada perbedaan jelas antara 200, 202, 409, dan 422?
- Apakah klien bisa melakukan rekonsiliasi setelah timeout?
- Apakah error model memiliki code, retryable, dan request_id?
- Apakah ada deduplikasi di level API dan penyimpanan?
- Apakah webhook memiliki event_id dan mekanisme ordering yang jelas?
- Apakah nilai field waktu, status, dan versi didefinisikan secara tegas?
- Apakah penambahan field baru aman untuk consumer lama?
- Apakah dokumentasi menjelaskan perilaku retry, bukan hanya schema endpoint?
Penutup
Kontrak API yang tidak rapuh saat retry, timeout, dan duplikasi bukan soal menambah banyak fitur defensif, melainkan soal memperjelas semantik operasi. Begitu intent operasi punya identitas, error punya arti, dan event punya deduplikasi serta ordering yang jelas, integrasi menjadi lebih sederhana untuk dipakai dan lebih mudah dipulihkan saat gagal.
Jika Anda harus memilih prioritas implementasi, mulai dari tiga hal ini: idempotency key untuk operasi yang punya efek samping, status code yang semantik, dan jalur rekonsiliasi setelah timeout. Tiga keputusan itu biasanya memberi dampak terbesar dalam menurunkan kompleksitas integrasi jangka panjang.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!