Kontrak API modular dibutuhkan ketika sistem backend terdiri dari banyak service, worker, gateway, dan integrasi pihak ketiga. Masalah yang paling sering muncul bukan pada format JSON, melainkan pada ambiguitas perilaku: apakah client harus login ulang, boleh retry, request sebelumnya sudah diproses atau belum, dan bagaimana membedakan error sementara dari error permanen.

Artikel ini membahas cara merancang kontrak API modular yang eksplisit pada tiga area yang paling sering gagal saat integrasi: auth, idempotensi, dan retry. Pendekatannya terinspirasi dari ide modularitas: setiap batas tanggung jawab harus jelas, sempit, dan dapat diuji terpisah. Fokusnya bukan pada kernel atau OS, tetapi pada integrasi aplikasi lintas tim dan lintas komponen.

Mengapa kontrak API harus modular

Pada sistem monolit kecil, banyak keputusan bisa disembunyikan di kode internal. Pada sistem dengan banyak komponen, asumsi tersembunyi berubah menjadi bug integrasi. Contohnya:

  • Gateway mengembalikan 500 untuk token kedaluwarsa, sehingga client melakukan retry sia-sia.
  • Endpoint pembayaran menerima POST dua kali karena timeout, lalu menciptakan dua transaksi.
  • Webhook diproses sesuai urutan kedatangan, padahal delivery bisa terlambat atau out-of-order.
  • Satu service menganggap 404 berarti resource belum ada, service lain memaknai bahwa caller tidak berhak melihat resource.

Kontrak modular memaksa kita memisahkan pertanyaan berikut:

  1. Auth: apakah caller terautentikasi dan berizin?
  2. Validasi: apakah input bisa diproses?
  3. Eksekusi: apakah operasi dijalankan, tertunda, atau gagal sementara?
  4. Idempotensi: apakah request yang sama aman dikirim ulang?
  5. Retry: kapan client harus mencoba lagi, dan kapan harus berhenti?

Jika kelima hal ini tidak tertulis jelas di kontrak, integrasi akan bergantung pada tebakan dan pembacaan kode sumber.

Batas tanggung jawab endpoint auth yang jelas

Kesalahan umum adalah mencampur status autentikasi, otorisasi, dan validasi domain dalam satu respons generik. API yang modular harus membedakan tiap kondisi secara eksplisit karena tindakan client berbeda.

Pemisahan dasar status auth

  • 401 Unauthorized: caller belum terautentikasi atau kredensial tidak valid.
  • 403 Forbidden: caller terautentikasi, tetapi tidak memiliki izin.
  • 400 Bad Request: format request salah, header wajib hilang, atau nilai tidak valid.
  • 409 Conflict: request valid, tetapi berbenturan dengan state sistem, misalnya idempotency key dipakai dengan payload berbeda.
  • 422 Unprocessable Entity: struktur request benar, namun aturan bisnis atau validasi semantik gagal.
  • 429 Too Many Requests: caller melampaui batas laju dan sebaiknya menunggu.
  • 5xx: kegagalan server atau dependency sementara, bukan kesalahan caller.

Dengan aturan ini, komponen auth tidak perlu “membocorkan” detail domain. Service pembayaran, misalnya, tidak perlu mengembalikan error campuran seperti AUTH_OR_AMOUNT_INVALID. Pisahkan jalurnya.

Header auth yang relevan

Minimal, tentukan header yang harus ada dan perilaku jika header itu tidak valid. Contoh kontrak yang sederhana:

Authorization: Bearer <access-token>
X-Request-Id: 2f7a0e8e-6f7d-4c57-9b2d-9e6b9a98a123
Accept: application/json
Content-Type: application/json

X-Request-Id tidak menggantikan idempotency key. Fungsinya untuk trace dan debugging lintas service. Satu request logis boleh punya banyak retry dengan request ID berbeda, tetapi tetap menggunakan idempotency key yang sama untuk operasi tertentu.

Contoh respons auth yang stabil

HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer realm="api"

{
  "error": {
    "code": "AUTH_INVALID_TOKEN",
    "message": "Access token tidak valid atau sudah kedaluwarsa.",
    "retryable": false
  }
}
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": {
    "code": "AUTH_INSUFFICIENT_SCOPE",
    "message": "Token tidak memiliki scope untuk membuat pembayaran.",
    "retryable": false
  }
}

Mengapa ini penting? Karena client harus tahu apakah perlu refresh token, meminta izin tambahan, atau berhenti total. Respons auth yang eksplisit mengurangi retry yang tidak perlu dan memudahkan observability.

Penamaan error yang stabil dan tidak bergantung implementasi internal

Nama error adalah bagian dari kontrak. Jika client harus melakukan percabangan logika, gunakan error code yang stabil, bukan mengandalkan isi pesan manusia.

Prinsip penamaan error

  • Gunakan code yang deterministik dan dapat diparsing mesin.
  • Jangan memakai nama exception internal sebagai kontrak publik.
  • Jangan menggabungkan banyak sebab dalam satu code.
  • Pesan manusia boleh berubah, code sebaiknya stabil.
  • Sertakan metadata bila perlu, tetapi jangan memaksa client mem-parsing teks bebas.

Contoh bentuk error envelope

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "error": {
    "code": "PAYMENT_AMOUNT_EXCEEDS_LIMIT",
    "message": "Nilai pembayaran melebihi limit harian akun.",
    "retryable": false,
    "details": {
      "field": "amount",
      "limit": "10000000"
    }
  }
}

Bentuk seperti ini memudahkan client menentukan perilaku:

  • retryable: false berarti retry otomatis tidak berguna.
  • details.field membantu UI menandai input.
  • code dipakai untuk branch logic atau telemetry.

Hindari code error yang terlalu generik

Contoh buruk:

  • INTERNAL_ERROR untuk semua kondisi.
  • INVALID_REQUEST untuk auth gagal, validasi gagal, dan konflik state.
  • FAILED tanpa konteks retryability.

Jika seluruh error pada akhirnya menjadi 500 dengan code yang sama, client tidak bisa membuat keputusan yang benar. Hasilnya biasanya adalah retry membabi buta, fallback yang salah, atau duplikasi side effect.

Idempotency key untuk POST sensitif

Secara HTTP, POST tidak idempotent. Namun banyak operasi bisnis penting tetap perlu menggunakan POST: membuat pembayaran, membuat order, melakukan transfer, atau mengirim perintah yang memicu side effect. Di sinilah idempotency key dibutuhkan.

Kapan wajib memakai idempotency key

Gunakan idempotency key pada POST yang memiliki satu atau lebih karakteristik berikut:

  • Menciptakan resource baru dengan biaya atau konsekuensi bisnis.
  • Memicu side effect eksternal, misalnya charge ke payment provider.
  • Rentan di-retry karena timeout jaringan atau failover.
  • Dapat dikirim ulang oleh job queue atau webhook relay.

Header dan semantik dasar

POST /v1/payments
Authorization: Bearer <token>
Idempotency-Key: pay_01J2WQ9W4N6N4F7H9M8A2BCDEF
Content-Type: application/json

Aturan yang sebaiknya eksplisit dalam kontrak:

  1. Idempotency key harus unik per operasi logis dari caller.
  2. Request dengan key yang sama dan payload identik harus menghasilkan hasil yang sama secara logis.
  3. Request dengan key yang sama tetapi payload berbeda harus ditolak, biasanya dengan 409 Conflict.
  4. Server harus menyimpan hasil atau status proses untuk jangka waktu tertentu. Jika retensi tepatnya bervariasi, dokumentasikan secara umum tanpa bergantung pada implementasi internal.

Contoh request dan response pembayaran

POST /v1/payments
Authorization: Bearer <token>
Idempotency-Key: pay_01J2WQ9W4N6N4F7H9M8A2BCDEF
Content-Type: application/json

{
  "customer_id": "cus_12345",
  "amount": 150000,
  "currency": "IDR",
  "reference": "INV-2026-0001"
}
HTTP/1.1 201 Created
Content-Type: application/json

{
  "data": {
    "payment_id": "pay_789",
    "status": "accepted",
    "customer_id": "cus_12345",
    "amount": 150000,
    "currency": "IDR",
    "reference": "INV-2026-0001"
  }
}

Jika client mengirim ulang request yang sama karena timeout setelah request pertama sebenarnya sudah sukses, server sebaiknya mengembalikan hasil logis yang sama, misalnya respons yang identik atau representasi status terbaru dari resource yang sama.

Kasus key sama, payload berbeda

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": {
    "code": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
    "message": "Idempotency key sudah digunakan untuk request lain.",
    "retryable": false
  }
}

Ini penting untuk mencegah caller tanpa sengaja “menimpa” identitas operasi logis dengan data berbeda.

Strategi implementasi server

Secara implementasi, server biasanya menyimpan:

  • Idempotency key
  • Identitas caller atau tenant
  • Fingerprint payload yang relevan
  • Status eksekusi: in-progress, succeeded, failed-retryable, failed-final
  • Referensi ke resource atau response cache

Trade-off utamanya:

  • Menyimpan full response memudahkan replay, tetapi lebih mahal.
  • Menyimpan reference resource lebih hemat, tetapi perlu rekonstruksi response.
  • Menyimpan hash payload efisien, tetapi pastikan canonicalization konsisten agar hash tidak berubah hanya karena urutan field berbeda.

Catatan: jangan menjadikan idempotency key sekadar header yang di-log tanpa enforcement. Jika key tidak benar-benar diperiksa terhadap payload dan hasil sebelumnya, Anda masih bisa mendapat side effect ganda.

Aturan retry yang aman dan bisa diotomatisasi

Retry hanya aman jika kontraknya jelas. Client harus tahu perbedaan antara error permanen dan error sementara. Server juga harus konsisten memberi sinyal.

Tabel keputusan retry

Status/KondisiAman untuk retry?Tindakan clientCatatan
Network timeout sebelum menerima responsYa, jika request idempotent atau memakai idempotency keyRetry dengan backoffKasus paling umum pada POST sensitif
401 UnauthorizedTidak langsungRefresh/reacquire token lalu kirim ulang jika sesuaiBedakan dari 403
403 ForbiddenTidakPerbaiki izin atau hentikanRetry otomatis biasanya sia-sia
400 Bad RequestTidakPerbaiki requestMasalah pada caller
409 Conflict karena idempotency key reuse dengan payload berbedaTidakGunakan key baru setelah investigasiJangan retry buta
422 Unprocessable EntityTidakPerbaiki data bisnisBukan kegagalan sementara
429 Too Many RequestsYaTunggu sesuai Retry-After atau backoffJangan retry agresif
502/503/504YaRetry dengan exponential backoff dan jitterUtamakan operasi idempotent
500 tanpa kejelasanTergantung, sebaiknya minimalkan kasus iniRetry terbatas jika operasi amanAlasan mengapa 500 generik adalah anti-pattern

Header dan sinyal yang membantu retry

  • Retry-After untuk 429 atau kadang 503.
  • X-Request-Id atau correlation ID untuk pelacakan kegagalan.
  • retryable pada body error untuk membantu SDK atau worker.

Pola retry yang disarankan

Gunakan exponential backoff dengan jitter, bukan interval tetap. Alasan utamanya adalah mencegah thundering herd saat banyak worker gagal bersamaan. Untuk operasi non-idempotent tanpa idempotency key, retry otomatis sangat berisiko karena dapat menggandakan side effect.

if response.status in [502, 503, 504, 429]:
    retry_with_exponential_backoff_and_jitter()
elif network_timeout and request.has_idempotency_key:
    retry_with_same_idempotency_key()
elif response.error.retryable == true:
    retry_with_limit()
else:
    fail_and_surface_to_caller()

Logika ini tampak sederhana, tetapi sangat bergantung pada kontrak server yang konsisten. Jika AUTH_INVALID_TOKEN kadang muncul sebagai 500, semua keputusan di atas runtuh.

Webhook dan out-of-order delivery

Webhook adalah area yang sering diabaikan dalam kontrak API modular. Banyak tim mengasumsikan satu event akan datang sekali, berurutan, dan tepat waktu. Di sistem nyata, asumsi itu salah.

Karakteristik webhook yang perlu diasumsikan sejak awal

  • Delivery bisa at-least-once, sehingga event dapat duplikat.
  • Delivery bisa out-of-order.
  • Consumer bisa sementara tidak tersedia.
  • Producer dan consumer mungkin memiliki clock yang tidak sinkron persis.

Kontrak webhook yang lebih aman

POST /webhooks/payment-events
Content-Type: application/json
X-Event-Id: evt_001
X-Event-Type: payment.updated
X-Event-Time: 2026-08-20T10:15:30Z
X-Signature: <signature>

{
  "event_id": "evt_001",
  "event_type": "payment.updated",
  "occurred_at": "2026-08-20T10:15:30Z",
  "data": {
    "payment_id": "pay_789",
    "status": "settled",
    "version": 4
  }
}

Field yang paling membantu untuk menangani out-of-order adalah salah satu dari:

  • version monotonik per resource, atau
  • occurred_at yang andal, meskipun tetap kurang kuat dibanding version untuk ordering per entity.

Strategi consumer untuk event out-of-order

Jangan memproses event hanya berdasarkan urutan kedatangan. Simpan state terakhir per resource, lalu bandingkan versi:

if incoming.version < current.version:
    ignore_as_stale()
elif incoming.version == current.version and incoming.event_id already processed:
    ignore_as_duplicate()
else:
    apply_update_atomically()

Jika producer tidak menyediakan version, consumer sering dipaksa menggunakan timestamp. Ini lebih lemah karena event dengan timestamp dekat atau perbedaan clock dapat memicu hasil ambigu. Bila memungkinkan, versi per aggregate jauh lebih aman.

Status respons webhook

Untuk webhook, respons HTTP juga harus sederhana dan tegas:

  • 2xx: event diterima atau diproses.
  • 4xx: payload/signature salah; producer biasanya tidak perlu retry tanpa perubahan.
  • 5xx: consumer gagal sementara; producer boleh retry.

Kesalahan umum adalah mengembalikan 200 OK walaupun event gagal diproses “agar tidak di-retry”. Ini hanya memindahkan masalah ke data yang diam-diam tidak sinkron.

Contoh skema kontrak yang konsisten

Berikut contoh bentuk kontrak yang bisa dipakai lintas endpoint agar client tidak perlu menebak-nebak struktur respons.

Respons sukses

{
  "data": {
    "payment_id": "pay_789",
    "status": "accepted"
  },
  "meta": {
    "request_id": "2f7a0e8e-6f7d-4c57-9b2d-9e6b9a98a123"
  }
}

Respons error

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Terlalu banyak request.",
    "retryable": true,
    "details": {
      "scope": "tenant"
    }
  },
  "meta": {
    "request_id": "2f7a0e8e-6f7d-4c57-9b2d-9e6b9a98a123"
  }
}

Keuntungan format seperti ini:

  • SDK dan middleware bisa memproses error secara seragam.
  • Request ID selalu tersedia untuk debugging.
  • Client tidak perlu menebak apakah key error bernama error, errors, atau message di setiap endpoint.

Anti-pattern yang perlu dihindari

1. Semua kegagalan menjadi 500 generik

Ini anti-pattern paling mahal. Client kehilangan kemampuan membedakan auth gagal, validasi gagal, dan gangguan dependency. Akibatnya retry tidak akurat dan observability buruk.

2. Status auth tidak eksplisit

Mencampur 401 dan 403 membuat client tidak tahu apakah perlu login ulang atau meminta izin tambahan. Untuk integrasi machine-to-machine, perbedaan ini sangat penting.

3. POST sensitif tanpa idempotency key

Timeout jaringan adalah kejadian normal. Tanpa idempotency key, retry yang tampak “aman” bisa memicu charge ganda, order ganda, atau email ganda.

4. Idempotency key tidak diikat ke caller dan payload

Jika key hanya dicek sebagai string unik global tanpa konteks caller atau fingerprint payload, collision dan perilaku aneh akan muncul saat traffic meningkat.

5. Error code berubah-ubah mengikuti implementasi internal

Misalnya hari ini USER_NOT_FOUND, besok CUSTOMER_MISSING, lusa nama exception Java dipaparkan langsung. Kontrak publik harus lebih stabil daripada struktur internal.

6. Retry otomatis untuk semua 4xx dan 5xx

Retry buta memperburuk load dan bisa menggandakan side effect. Retry harus didasarkan pada semantik error, bukan sekadar kelas status.

7. Webhook dianggap exactly-once dan ordered

Asumsi ini hampir selalu salah. Jika consumer tidak tahan terhadap duplikasi dan out-of-order, inkonsistensi data hanya soal waktu.

Tips debugging dan operasional

  • Selalu log request ID, idempotency key, dan error code pada semua hop penting.
  • Buat dashboard terpisah untuk 401, 403, 409, 429, dan 5xx. Masing-masing menunjukkan kelas masalah yang berbeda.
  • Uji skenario timeout setelah commit tetapi sebelum respons terkirim. Ini skenario nyata yang sering memicu duplikasi.
  • Tambahkan test kontrak untuk request yang diulang dengan key sama dan payload berbeda.
  • Simulasikan webhook duplikat dan out-of-order pada environment integrasi, bukan hanya unit test.

Checklist review kontrak API sebelum integrasi lintas tim

  1. Apakah endpoint auth, validasi, dan error domain dibedakan secara eksplisit?
  2. Apakah 401 dan 403 dipakai konsisten dengan makna yang benar?
  3. Apakah ada daftar error code yang stabil dan terdokumentasi?
  4. Apakah setiap error menyatakan apakah ia retryable atau tidak, baik lewat status, header, body, atau kombinasi yang konsisten?
  5. Apakah semua POST sensitif mendukung Idempotency-Key?
  6. Apakah reuse idempotency key dengan payload berbeda ditolak secara eksplisit?
  7. Apakah kontrak menjelaskan perilaku saat timeout atau saat response tidak diterima client?
  8. Apakah aturan retry untuk 429, 502, 503, dan 504 terdokumentasi?
  9. Apakah tersedia Retry-After ketika memang relevan?
  10. Apakah struktur respons sukses dan error konsisten lintas endpoint?
  11. Apakah request ID atau correlation ID selalu tersedia untuk tracing?
  12. Apakah webhook memiliki event ID unik, signature, dan informasi ordering seperti version?
  13. Apakah consumer webhook dirancang untuk duplikasi dan out-of-order delivery?
  14. Apakah dokumentasi menjelaskan trade-off, bukan hanya happy path?
  15. Apakah ada test kontrak lintas tim untuk auth, idempotensi, retry, dan webhook edge case?

Penutup

Kontrak API modular: auth, idempotensi, dan retry tanpa ambigu bukan soal menambah banyak field, tetapi soal membuat keputusan mesin menjadi dapat diprediksi. Ketika auth dibedakan jelas, error code stabil, POST sensitif memakai idempotency key, dan retry diatur berdasarkan semantik yang benar, integrasi lintas komponen menjadi jauh lebih aman.

Jika Anda harus memilih prioritas awal, mulai dari tiga hal ini: status auth yang eksplisit, error code yang stabil, dan idempotency key untuk operasi yang memicu side effect. Tiga fondasi itu biasanya menghilangkan sebagian besar bug integrasi yang paling mahal.