Kontrak error API yang stabil menentukan apakah integrasi antarsistem akan mudah dipelihara atau justru rapuh setiap kali ada perubahan kecil. Jika error hanya berupa pesan teks yang berubah-ubah, client sulit membedakan mana kegagalan validasi, mana kondisi sementara yang boleh di-retry, dan mana error internal yang harus diinvestigasi.

Solusi yang aman adalah mendefinisikan response error yang konsisten, machine-readable, dan terdokumentasi. Artinya, status HTTP digunakan dengan tepat, body error memiliki kode yang stabil, ada request/correlation ID untuk pelacakan, detail validasi tersusun rapi, serta ada penanda apakah request layak di-retry. Dengan pola ini, integrasi untuk aplikasi web, mobile, webhook consumer, hingga background worker menjadi lebih dapat diprediksi.

Mengapa kontrak error API perlu stabil

Dalam integrasi nyata, pihak pemanggil API jarang hanya menampilkan pesan error ke pengguna. Mereka biasanya harus mengambil keputusan teknis, misalnya:

  • apakah request perlu diulang otomatis,
  • apakah error harus masuk ke dead-letter queue,
  • apakah webhook perlu dianggap gagal dan dicoba ulang,
  • apakah insiden perlu dinaikkan ke sistem monitoring,
  • atau apakah pengguna perlu diminta memperbaiki input.

Keputusan-keputusan itu tidak boleh bergantung pada string seperti "Email tidak valid" atau "Terjadi kesalahan", karena teks mudah berubah akibat refactor, lokalisasi, atau penyesuaian copywriting. Yang harus stabil adalah struktur dan kode error-nya.

Kontrak error yang baik memberi dua lapisan informasi:

  • Untuk mesin: status HTTP, error code, retryable flag, field validation detail, dan ID pelacakan.
  • Untuk manusia: pesan singkat yang membantu debugging tanpa membocorkan detail sensitif.

Struktur response error yang direkomendasikan

Tidak ada satu standar tunggal yang wajib dipakai di semua sistem, tetapi bentuk berikut praktis dan mudah dipahami lintas tim. Yang paling penting adalah stabilitas field, bukan nama persisnya.

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Permintaan tidak valid.",
    "retryable": false,
    "request_id": "req_7f3c9b2a",
    "details": [
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Format email tidak valid."
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Nilai age harus antara 18 dan 65."
      }
    ]
  }
}

Field yang umumnya berguna:

  • code: kode error stabil untuk logika client, misalnya VALIDATION_FAILED, RATE_LIMITED, RESOURCE_NOT_FOUND, INTERNAL_ERROR.
  • message: penjelasan singkat untuk manusia. Jangan jadikan ini dasar branching di client.
  • retryable: boolean yang membantu worker atau integrator menentukan apakah request layak dicoba ulang.
  • request_id: ID unik untuk korelasi log, tracing, dan tiket support.
  • details: rincian tambahan, terutama untuk validasi field atau error domain yang memiliki beberapa sub-item.

Prinsip desain field

  • Jangan ganti makna field tanpa versi baru. Jika retryable berarti “aman untuk retry otomatis”, jangan ubah menjadi “mungkin bisa dicoba lagi secara manual”.
  • Jangan hapus field penting diam-diam. Client lama bisa rusak meski response masih JSON valid.
  • Tambahkan field baru secara kompatibel. Penambahan biasanya aman jika client diharapkan mengabaikan field yang tidak dikenal.
  • Hindari detail internal sensitif, seperti stack trace, query database, path file, atau nama service internal.

Status HTTP yang tepat dan dokumentasi perilaku 4xx vs 5xx

Status HTTP harus merepresentasikan kategori kegagalan. Ini penting karena banyak client, proxy, gateway, job runner, dan SDK mengambil keputusan dasar dari status code sebelum membaca body.

Klasifikasi praktis error

KondisiStatus HTTPContoh codeRetry?Catatan
Payload tidak valid400 atau 422VALIDATION_FAILEDTidakGunakan detail per field agar client bisa memperbaiki input.
Autentikasi gagal401UNAUTHORIZEDTidak langsungBisa berhasil setelah token diperbarui.
Akses dilarang403FORBIDDENTidakBukan masalah sementara.
Resource tidak ditemukan404RESOURCE_NOT_FOUNDTergantung konteksBiasanya tidak, kecuali ada eventual consistency.
Konflik state/idempoten409CONFLICTTergantung konteksCocok untuk duplikasi atau perubahan state yang bentrok.
Rate limit429RATE_LIMITEDYaIdealnya sertakan petunjuk jeda retry.
Gangguan downstream/internal500INTERNAL_ERRORYa, hati-hatiRetry otomatis sebaiknya dibatasi dan memakai backoff.
Service sementara tidak siap503SERVICE_UNAVAILABLEYaLebih jelas daripada semua kegagalan dijadikan 500.
Timeout upstream/gateway504UPSTREAM_TIMEOUTYaMenandakan kegagalan sementara, bukan input salah.

Kapan memakai 400 dan kapan 422

Dalam praktik, keduanya sering dipakai untuk input salah. Yang penting adalah konsistensi di seluruh API. Jika tim memilih:

  • 400 untuk request yang salah secara umum, gunakan secara konsisten.
  • 422 untuk payload yang secara struktur bisa diparse tetapi gagal validasi bisnis/field, dokumentasikan aturan itu dengan jelas.

Masalah terbesar bukan memilih 400 atau 422, melainkan mencampur keduanya tanpa aturan yang jelas.

Dokumentasikan perilaku 4xx vs 5xx

Aturan yang mudah dipahami oleh integrator:

  • 4xx berarti ada yang perlu diperbaiki di sisi pemanggil: input, autentikasi, otorisasi, atau state request.
  • 5xx berarti kegagalan sementara atau internal di sisi penyedia API atau dependensinya.

Untuk integrasi yang aman, dokumentasikan bukan hanya status code, tetapi juga aksi yang diharapkan: apakah client harus memperbaiki request, memperbarui kredensial, menunggu dan retry, atau menghubungi support dengan request ID.

Machine-readable error code: fondasi integrasi yang tahan perubahan

Error code adalah kontrak utama untuk percabangan logika. Client bisa menulis aturan yang stabil seperti:

  • jika code=RATE_LIMITED, tunggu lalu retry,
  • jika code=VALIDATION_FAILED, tampilkan error per field,
  • jika code=UNAUTHORIZED, lakukan refresh token atau minta login ulang,
  • jika code=INTERNAL_ERROR, catat request ID dan jalankan retry terbatas.

Karakteristik error code yang baik:

  • Stabil: tidak berubah hanya karena teks pesan diubah.
  • Cukup umum: jangan terlalu granular jika belum benar-benar dibutuhkan.
  • Berorientasi aksi: memudahkan client mengambil keputusan.

Contoh hierarki yang praktis:

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Terlalu banyak request.",
    "retryable": true,
    "request_id": "req_ab12cd34"
  }
}

Untuk validasi field, gunakan kode global dan kode detail per item:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Permintaan tidak valid.",
    "retryable": false,
    "request_id": "req_91ef23aa",
    "details": [
      { "field": "phone", "code": "REQUIRED", "message": "phone wajib diisi." },
      { "field": "email", "code": "INVALID_FORMAT", "message": "Format email tidak valid." }
    ]
  }
}

Pola ini memudahkan UI menandai field bermasalah, sekaligus memudahkan sistem otomatis mengenali bahwa kategori error tetap VALIDATION_FAILED.

Correlation ID dan request ID untuk debugging dan observability

Ketika integrasi melibatkan API gateway, beberapa service internal, queue, dan webhook, satu pesan error tanpa ID pelacakan hampir tidak berguna untuk investigasi. Karena itu, sertakan request ID di response error dan, bila memungkinkan, terima atau propagasikan correlation ID dari request masuk.

Perbedaan praktisnya

  • Request ID: unik untuk satu request yang diproses API.
  • Correlation ID: menghubungkan beberapa request atau event dalam satu alur bisnis yang sama.

Dalam banyak sistem, satu ID saja sudah cukup untuk tahap awal, selama ia konsisten muncul di response, log aplikasi, tracing, dan dashboard observability.

Contoh header dan body:

HTTP/1.1 503 Service Unavailable
Content-Type: application/json
X-Request-Id: req_f6a8102c

{
  "error": {
    "code": "SERVICE_UNAVAILABLE",
    "message": "Layanan sedang tidak tersedia.",
    "retryable": true,
    "request_id": "req_f6a8102c"
  }
}

Mengapa tetap ada di header dan body?

  • Header memudahkan middleware, reverse proxy, dan tooling HTTP.
  • Body memudahkan client yang menyimpan payload error apa adanya ke log atau audit trail.

Dampak ke observability

  • Logging: tim support bisa mencari semua log terkait request_id.
  • Tracing: lebih mudah melihat titik gagal di service internal.
  • Alerting: error 5xx dapat dikelompokkan berdasarkan code dan endpoint, bukan hanya status mentah.
  • Webhook troubleshooting: consumer bisa melaporkan ID spesifik saat meminta investigasi.

Retryability flag: kapan client boleh mencoba ulang

Tidak semua client membaca status HTTP dengan cermat, dan tidak semua status code cukup untuk keputusan retry yang aman. Menambahkan field retryable membantu menyederhanakan integrasi, terutama untuk worker, scheduler, dan webhook consumer.

Contoh error yang layak di-retry:

  • SERVICE_UNAVAILABLE
  • UPSTREAM_TIMEOUT
  • RATE_LIMITED

Contoh yang umumnya tidak layak di-retry tanpa perubahan request:

  • VALIDATION_FAILED
  • FORBIDDEN
  • RESOURCE_NOT_FOUND dalam sistem tanpa eventual consistency

Catatan penting tentang retry

  • Retryable tidak selalu berarti aman diulang tanpa desain idempoten.
  • Untuk operasi yang membuat data, pertimbangkan idempotency key agar retry tidak menyebabkan duplikasi.
  • Gunakan exponential backoff dan batas maksimum retry agar tidak memperparah kegagalan sistem.

Contoh logika sederhana di worker:

if response.status in [500, 503, 504] and error.retryable == true:
    retry_with_backoff()
elif response.status == 429 and error.retryable == true:
    retry_after_delay()
else:
    move_to_dead_letter_or_fail()

Walau pseudo-code ini sederhana, prinsipnya jelas: keputusan retry harus berdasar sinyal yang stabil, bukan regex atas teks pesan.

Dampak pada client, webhook consumer, dan worker

Client aplikasi

Client frontend atau mobile membutuhkan pembedaan yang jelas antara error yang bisa diperbaiki pengguna dan error sistem. Dengan kontrak yang stabil:

  • field validasi bisa ditampilkan tepat di form,
  • autentikasi kadaluwarsa bisa diarahkan ke refresh token atau login ulang,
  • error 5xx bisa ditampilkan sebagai gangguan sementara tanpa menyalahkan input pengguna.

Webhook consumer

Webhook biasanya berjalan tanpa interaksi manusia. Consumer harus memutuskan apakah event akan diproses ulang atau diparkir untuk investigasi. Jika semua respons sukses/gagal disamarkan, misalnya selalu 200 dengan body {"success":false}, maka platform pengirim bisa salah menganggap delivery berhasil dan berhenti retry.

Untuk webhook, status HTTP sangat penting:

  • kembalikan 2xx hanya jika event benar-benar diterima dan diproses atau minimal diterima untuk diproses aman,
  • gunakan 4xx bila payload tidak valid atau otorisasi salah,
  • gunakan 5xx untuk gangguan sementara agar pengirim bisa retry sesuai kebijakannya.

Background worker dan queue consumer

Worker membutuhkan sinyal yang dapat diotomatisasi. Kontrak error yang baik membantu menentukan:

  • langsung gagal permanen,
  • retry dengan backoff,
  • kirim ke dead-letter queue,
  • atau eskalasi insiden.

Tanpa kode error yang stabil, worker sering berakhir memakai aturan rapuh seperti mencocokkan substring "timeout" atau "temporarily unavailable" dari pesan error.

Anti-pattern yang sering merusak integrasi

1. Semua error dikembalikan sebagai 200

Ini memaksa client membaca body untuk mengetahui kegagalan dan sering merusak integrasi dengan gateway, cache, observability, dan webhook retrier yang mengandalkan status HTTP.

2. Semua kegagalan dijadikan 500

Akibatnya, input yang salah tampak seperti masalah server. Client bisa terus retry request yang sebenarnya tidak akan pernah berhasil.

3. Mengandalkan message untuk percabangan logika

Pesan berubah karena lokalisasi, koreksi ejaan, atau perubahan gaya bahasa. Begitu teks berubah, client yang melakukan pencocokan string ikut rusak.

4. Struktur error berbeda-beda antar endpoint

Misalnya endpoint A mengirim {error: "..."}, endpoint B mengirim {message: "..."}, endpoint C mengirim array. Integrator harus menulis parser khusus per endpoint, padahal kategori error serupa.

5. Tidak ada request ID

Tanpa ID pelacakan, investigasi menjadi lambat karena tim harus menebak log mana yang sesuai dengan laporan client.

6. Validasi hanya satu pesan umum tanpa detail field

UI dan API consumer kesulitan memberi umpan balik yang spesifik. Hasilnya, pengguna harus menebak field mana yang salah.

7. Membocorkan detail internal

Stack trace, query SQL, nama tabel, atau detail service internal bisa menambah risiko keamanan dan tidak membantu integrator umum.

Contoh kontrak error yang lebih lengkap

Contoh berikut cukup kaya untuk banyak kebutuhan integrasi, tetapi masih sederhana untuk dipertahankan dalam jangka panjang.

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Terlalu banyak request.",
    "retryable": true,
    "request_id": "req_c1d2e3f4",
    "details": {
      "scope": "api_key",
      "limit_type": "per_minute"
    }
  }
}

Untuk validasi:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Permintaan tidak valid.",
    "retryable": false,
    "request_id": "req_55aa77bb",
    "details": [
      {
        "field": "customer_id",
        "code": "REQUIRED",
        "message": "customer_id wajib diisi."
      },
      {
        "field": "amount",
        "code": "MIN_VALUE",
        "message": "amount harus lebih besar dari 0."
      }
    ]
  }
}

Perhatikan bahwa details bisa berupa array untuk validasi banyak field, atau objek untuk metadata tambahan pada error non-validasi. Jika memilih pola ini, dokumentasikan dengan tegas agar client tahu kapan ia menerima array dan kapan objek. Alternatif yang lebih aman adalah memisahkan menjadi field berbeda seperti validation_errors dan metadata.

Panduan implementasi agar kompatibel saat API berkembang

1. Definisikan katalog error pusat

Simpan daftar error code, arti, status HTTP, dan retryability di satu tempat. Ini mencegah setiap endpoint membuat istilah sendiri.

2. Pisahkan pesan manusia dari kontrak mesin

Biarkan message berubah seperlunya, tetapi pastikan code, struktur field, dan makna semantiknya stabil.

3. Gunakan middleware atau komponen bersama

Pembuatan response error sebaiknya tidak diulang manual di setiap controller atau handler. Komponen bersama memudahkan konsistensi header, request ID, dan format body.

4. Dokumentasikan contoh sukses dan gagal

Integrator biasanya lebih terbantu oleh contoh payload nyata daripada definisi abstrak. Sertakan contoh 400, 401, 404, 429, dan 5xx yang paling relevan.

5. Perlakukan perubahan kontrak error sebagai perubahan API

Mengganti nama code, menghapus field, atau mengubah tipe data harus diperlakukan seperti perubahan kontrak lain. Jangan anggap aman hanya karena endpoint utama tidak berubah.

6. Rancang untuk forward compatibility

Client sebaiknya diajarkan untuk mengabaikan field baru yang tidak dikenal. Ini memberi ruang evolusi tanpa langsung mematahkan integrasi lama.

Checklist implementasi

  1. Setiap error memakai status HTTP yang sesuai.
  2. Body error memiliki struktur konsisten di semua endpoint.
  3. Ada error.code yang stabil dan machine-readable.
  4. Ada error.message yang aman untuk manusia.
  5. Ada error.request_id dan, jika relevan, header request ID.
  6. Ada error.retryable untuk membantu automasi retry.
  7. Error validasi menyertakan detail per field.
  8. Dokumentasi menjelaskan arti 4xx vs 5xx dan aksi yang diharapkan client.
  9. Response tidak membocorkan detail internal sensitif.
  10. Ada katalog error code yang dikelola lintas tim.
  11. Worker dan webhook consumer diuji terhadap skenario retry dan non-retry.
  12. Perubahan kontrak error masuk ke proses review API, bukan hanya review implementasi.

Checklist pengujian kontrak error

Contract testing

  • Verifikasi field wajib selalu ada: code, message, request_id, dan field lain yang ditetapkan tim.
  • Verifikasi tipe data tidak berubah, misalnya retryable tetap boolean.
  • Verifikasi endpoint yang serupa mengembalikan format error yang seragam.

Integration testing

  • Uji bahwa 4xx tidak memicu retry otomatis di worker.
  • Uji bahwa 429/503/504 memicu retry dengan backoff.
  • Uji bahwa request ID tercatat di log dan dapat ditelusuri.

Negative testing

  • Kirim payload kosong, field salah tipe, token invalid, dan konflik state.
  • Simulasikan kegagalan downstream untuk memastikan 5xx dan retryable sesuai.

Backward compatibility testing

  • Pastikan penambahan field baru tidak mematahkan client lama.
  • Pastikan perubahan pesan teks tidak memengaruhi logic client karena branch berbasis code.

Penutup

Kontrak Error API yang Stabil untuk Retry dan Integrasi Aman bukan sekadar soal format JSON yang rapi. Ini adalah fondasi agar client, webhook consumer, worker, dan sistem observability bisa mengambil keputusan yang benar tanpa bergantung pada asumsi rapuh. Gunakan status HTTP secara semantik, sediakan error code yang stabil, sertakan request ID, tampilkan detail validasi yang terstruktur, dan dokumentasikan kapan 4xx harus diperbaiki versus kapan 5xx boleh di-retry.

Jika Anda hanya memperbaiki satu hal mulai hari ini, perbaiki dua komponen inti ini: status HTTP yang benar dan machine-readable error code yang stabil. Dari sana, retry policy, debugging, dan evolusi API akan menjadi jauh lebih aman.