Validasi menolak payload yang buruk dengan 400, tetapi kegagalan API tidak berhenti di situ: resource hilang, data bertabrakan, layanan dependensi tumbang, kode kita sendiri berisi kesalahan. Cara API merespons kegagalan adalah bagian dari kontraknya — klien yang baik membangun logika retry, tampilan galat, dan pelaporan berdasarkan status code serta isi body yang kita kirim. API yang membalas kegagalan dengan stack trace mentah bukan sekadar tidak rapi; karena detail internal ikut terkirim, respons semacam itu menjadi risiko keamanan. Bab ini menyusun penanganan kesalahan yang seragam memakai format Problem Details (RFC 9457), lalu membuktikan mekanisme pemetaan exception-ke-respons dengan program Java murni.
Kode Status yang Benar untuk Kegagalan yang Benar #
Pemetaan kegagalan ke status code perlu disepakati sejak desain, bukan ditambal belakangan:
| Situasi | Status | Catatan |
|---|---|---|
| Payload tidak lolos validasi | 400 atau 422 | 422 bila sintaks JSON benar tapi semantiknya salah |
| Tidak terautentikasi | 401 | kredensial hilang atau salah |
| Ter-autentikasi tapi dilarang | 403 | identitas dikenal, akses ditolak |
| Resource tidak ada | 404 | atau 410 bila memang sudah dihapus permanen |
| Konflik keadaan | 409 | duplikat, versi bertabrakan |
| Kesalahan internal | 500 | tanpa detail internal di body |
| Dependensi tidak siap | 503 | disertai header Retry-After bila memungkinkan |
Problem Details sebagai Format Seragam #
RFC 9457 menstandardkan body untuk semua kegagalan itu dengan media type application/problem+json. Lima field intinya:
| Field | Isi | Contoh |
|---|---|---|
type | URI penjelas jenis masalah | https://tokobudi.example/masalah/tidak-ditemukan |
title | ringkasan tetap per jenis | Tidak ditemukan |
status | kode status HTTP | 404 |
detail | penjelasan spesifik kejadian ini | produk 99 tidak ada |
instance | URI kejadian | /produk/99 |
Nilai type sengaja berupa URI, bukan kode angka: alamat itu bisa menunjuk halaman dokumentasi yang menjelaskan jenis masalah dan cara mengatasinya. Format ini sudah pula diadopsi Jakarta RESTful Web Services 4.0 sebagai class ProblemDetail siap pakai.
Pemetaan Terpusat #
Prinsip pelaksanaannya: kode bisnis melempar exception bermakna, satu titik tangkapan mengubah seluruh exception menjadi Problem Details. Di Jakarta REST, titik itu adalah ExceptionMapper; di Spring MVC, @ControllerAdvice:
@Provider
public class PemetaanKesalahan implements ExceptionMapper<TidakDitemukanException> {
@Override
public Response toResponse(TidakDitemukanException e) {
return Response.status(404)
.type(MediaType.APPLICATION_PROBLEM_JSON)
.entity(new ProblemDetail(
URI.create("https://tokobudi.example/masalah/tidak-ditemukan"),
"Tidak ditemukan", 404, e.getMessage(), null))
.build();
}
}Kode endpoint tetap bersih: throw new TidakDitemukanException("produk 99 tidak ada") dan pemetaan bekerja untuk seluruh aplikasi.
Pemetaan dalam Java Murni #
Program PetaKesalahan mewujudkan arsitektur yang sama tanpa container: hierarki exception membawa status dan judul, method tangani menangkap setiap kegagalan lalu merakit Problem Details:
public class PetaKesalahan {
static class ApiException extends RuntimeException {
final int status;
final String judul;
ApiException(int status, String judul, String detail) {
super(detail);
this.status = status;
this.judul = judul;
}
}
static class TidakDitemukanException extends ApiException {
TidakDitemukanException(String detail) {
super(404, "Tidak ditemukan", detail);
}
}
static class KonflikException extends ApiException {
KonflikException(String detail) {
super(409, "Konflik", detail);
}
}
static class ValidasiException extends ApiException {
ValidasiException(String detail) {
super(400, "Validasi gagal", detail);
}
}
record ProblemDetail(String tipe, String judul, int status, String detail, String instance) {
String json() {
return "{\"tipe\":\"" + tipe + "\",\"judul\":\"" + judul
+ "\",\"status\":" + status
+ ",\"detail\":\"" + detail
+ "\",\"instance\":\"" + instance + "\"}";
}
}
static String tangani(String instance, Runnable aksi) {
try {
aksi.run();
return instance + " -> 200 OK (tanpa problem)";
} catch (ApiException e) {
ProblemDetail problem = new ProblemDetail(
"https://tokobudi.example/masalah/" + jenis(e.judul),
e.judul, e.status, e.getMessage(), instance);
return instance + " -> " + problem.json();
} catch (RuntimeException e) {
System.out.println("[log server] RuntimeException tak terduga: "
+ e.getClass().getSimpleName() + ": " + e.getMessage());
ProblemDetail problem = new ProblemDetail(
"https://tokobudi.example/masalah/server",
"Kesalahan server internal", 500,
"Terjadi kesalahan internal. Silakan coba lagi nanti.", instance);
return instance + " -> " + problem.json();
}
}
static String jenis(String judul) {
return judul.toLowerCase().replace(' ', '-');
}
public static void main(String[] args) {
System.out.println(tangani("/produk/99",
() -> { throw new TidakDitemukanException("produk 99 tidak ada"); }));
System.out.println();
System.out.println(tangani("/produk",
() -> { throw new ValidasiException("field harga wajib positif"); }));
System.out.println();
System.out.println(tangani("/produk/3",
() -> { throw new KonflikException("nama sudah dipakai produk lain"); }));
System.out.println();
System.out.println(tangani("/produk/5",
() -> { throw new IllegalStateException("koneksi basis data terputus"); }));
System.out.println();
System.out.println(tangani("/produk/2", () -> { }));
}
}Output yang Dihasilkan:
/produk/99 -> {"tipe":"https://tokobudi.example/masalah/tidak-ditemukan","judul":"Tidak ditemukan","status":404,"detail":"produk 99 tidak ada","instance":"/produk/99"}
/produk -> {"tipe":"https://tokobudi.example/masalah/validasi-gagal","judul":"Validasi gagal","status":400,"detail":"field harga wajib positif","instance":"/produk"}
/produk/3 -> {"tipe":"https://tokobudi.example/masalah/konflik","judul":"Konflik","status":409,"detail":"nama sudah dipakai produk lain","instance":"/produk/3"}
[log server] RuntimeException tak terduga: IllegalStateException: koneksi basis data terputus
/produk/5 -> {"tipe":"https://tokobudi.example/masalah/server","judul":"Kesalahan server internal","status":500,"detail":"Terjadi kesalahan internal. Silakan coba lagi nanti.","instance":"/produk/5"}
/produk/2 -> 200 OK (tanpa problem)Bedah arsitektur kecil ini:
- Baris 3-12 (
ApiException): kelas dasar membawa pasangan(status, judul)— data yang dibutuhkan pemetaan, bukan sekadar pesan. Seluruh exception turunannya satu baris definisi karena statusnya melekat pada jenisnya, bukan pada tempat pelemparan. - Baris 32-40 (
record ProblemDetail): kelima field RFC 9457 menjadi komponen record; methodjson()hanyalah penyusun string. Pada Jakarta REST sungguhan, classProblemDetaildarijakarta.ws.rs.coremelakukan pekerjaan ini dan container yang menserialisasikannya. - Baris 46-50 (
catch ApiException): satu cabang menangani seluruh exception bisnis — jenis exception menentukan status, judul, dan slugtype. Menambah jenis kegagalan baru (misalnyaKonflikException) tidak menuntut perubahan padatangani. - Baris 51-59 (
catch RuntimeException): jaring pengaman untuk yang tak terduga. Detail aslinya (koneksi basis data terputus) hanya dicatat ke log server; klien menerima pesan generik. Output memperlihatkan kedua arus tersebut: baris[log server]di terminal operator, JSON generik di jalur klien. - Baris 62-64 (
jenis): judul “Tidak ditemukan” menjadi slugtidak-ditemukanlewattoLowerCasedan penggantian spasi — konvensi kecil yang menjaga URLtypetetap konsisten.
Kini API kita bisa menerima data dengan aman dan gagal dengan sopan. Satu wilayah terakhir yang menentukan layak-tidaknya aplikasi web naik ke produksi: keamanan — mulai dari injection, otentikasi kata sandi, sampai CSRF. Chapter penutup Section ini membedah praktik aman yang paling relevan bagi aplikasi web Java.
Referensi #
- RFC 9457: Problem Details for HTTP APIs
- Jakarta RESTful Web Services 4.0 Specification
- Spring Boot Reference Documentation
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
PetaKesalahan.java
Unduh berkaspublic class PetaKesalahan {
static class ApiException extends RuntimeException {
final int status;
final String judul;
ApiException(int status, String judul, String detail) {
super(detail);
this.status = status;
this.judul = judul;
}
}
static class TidakDitemukanException extends ApiException {
TidakDitemukanException(String detail) {
super(404, "Tidak ditemukan", detail);
}
}
static class KonflikException extends ApiException {
KonflikException(String detail) {
super(409, "Konflik", detail);
}
}
static class ValidasiException extends ApiException {
ValidasiException(String detail) {
super(400, "Validasi gagal", detail);
}
}
record ProblemDetail(String tipe, String judul, int status, String detail, String instance) {
String json() {
return "{\"tipe\":\"" + tipe + "\",\"judul\":\"" + judul
+ "\",\"status\":" + status
+ ",\"detail\":\"" + detail
+ "\",\"instance\":\"" + instance + "\"}";
}
}
static String tangani(String instance, Runnable aksi) {
try {
aksi.run();
return instance + " -> 200 OK (tanpa problem)";
} catch (ApiException e) {
ProblemDetail problem = new ProblemDetail(
"https://tokobudi.example/masalah/" + jenis(e.judul),
e.judul, e.status, e.getMessage(), instance);
return instance + " -> " + problem.json();
} catch (RuntimeException e) {
System.out.println("[log server] RuntimeException tak terduga: "
+ e.getClass().getSimpleName() + ": " + e.getMessage());
ProblemDetail problem = new ProblemDetail(
"https://tokobudi.example/masalah/server",
"Kesalahan server internal", 500,
"Terjadi kesalahan internal. Silakan coba lagi nanti.", instance);
return instance + " -> " + problem.json();
}
}
static String jenis(String judul) {
return judul.toLowerCase().replace(' ', '-');
}
public static void main(String[] args) {
System.out.println(tangani("/produk/99",
() -> { throw new TidakDitemukanException("produk 99 tidak ada"); }));
System.out.println();
System.out.println(tangani("/produk",
() -> { throw new ValidasiException("field harga wajib positif"); }));
System.out.println();
System.out.println(tangani("/produk/3",
() -> { throw new KonflikException("nama sudah dipakai produk lain"); }));
System.out.println();
System.out.println(tangani("/produk/5",
() -> { throw new IllegalStateException("koneksi basis data terputus"); }));
System.out.println();
System.out.println(tangani("/produk/2", () -> { }));
}
}