Tampilan
Kenapa penting
Klien API — aplikasi mobile, frontend, atau service lain — tidak bisa membaca pikiranmu. Kalau setiap endpoint mengembalikan error dengan bentuk berbeda (kadang teks, kadang {"error": ...}, kadang halaman HTML), setiap klien harus menulis kode khusus untuk menebaknya. Lebih buruk lagi, error yang tidak ditangani bisa membocorkan stack trace, nama tabel, atau alamat database ke internet. Di tim backend, format error adalah bagian dari kontrak API: statusnya harus tepat, body-nya seragam, pesannya bisa dipahami, dan setiap error 500 harus bisa dilacak di log. Bab ini menyatukan exception domain dari M03, validasi DTO dari bab sebelumnya, dan standar RFC 9457 menjadi satu penanganan error terpusat.
Konsep inti
RFC 9457 dan ProblemDetail
RFC 9457 Problem Details for HTTP APIs (pengganti RFC 7807) mendefinisikan body error standar dengan media type application/problem+json. Spring Framework memodelkannya sebagai class ProblemDetail.
json
{
"type": "https://api.tokoku.id/problems/insufficient-stock",
"title": "Stok tidak cukup",
"status": 422,
"detail": "Stok TEH-100 tidak cukup",
"instance": "/api/orders",
"requested": 5,
"available": 2
}| Field | Arti |
|---|---|
type | URI yang mengidentifikasi jenis masalah; default about:blank |
title | ringkasan singkat jenis masalah, sama untuk setiap kemunculan jenis yang sama |
status | status HTTP, diulang di body agar mudah dibaca |
detail | penjelasan kejadian ini untuk manusia |
instance | URI kejadian — Spring mengisinya dengan path request bila kosong |
Field tambahan (extension) seperti requested dan available boleh ditambahkan lewat setProperty(...); Spring menuliskannya sebagai properti JSON level atas. Klien sebaiknya bercabang berdasarkan type, bukan mencocokkan teks detail yang bisa berubah.
Memetakan exception domain ke status
Di proyek mini M03, InventoryService melempar exception domain unchecked — justru agar exception bisa mengalir tanpa throws di setiap lapisan dan ditangani di satu tempat. Tempat itu sekarang ada. Sebelum masuk Spring, lihat idenya dalam Java murni: satu method yang memetakan exception ke status dan body.
java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
class ProductNotFoundException extends RuntimeException {
ProductNotFoundException(String sku) {
super("Produk tidak ditemukan: " + sku);
}
}
class DuplicateSkuException extends RuntimeException {
DuplicateSkuException(String sku) {
super("SKU sudah terdaftar: " + sku);
}
}
class InsufficientStockException extends RuntimeException {
private final int requested;
private final int available;
InsufficientStockException(String sku, int requested, int available) {
super("Stok " + sku + " tidak cukup");
this.requested = requested;
this.available = available;
}
int requested() { return requested; }
int available() { return available; }
}
public class ErrorMappingDemo {
// Meniru apa yang nanti dilakukan @RestControllerAdvice: exception → status + body ProblemDetail
static Map<String, Object> toProblem(RuntimeException ex, String path) {
Map<String, Object> body = new LinkedHashMap<>();
switch (ex) {
case ProductNotFoundException e -> {
body.put("status", 404);
body.put("title", "Produk tidak ditemukan");
body.put("detail", e.getMessage());
}
case DuplicateSkuException e -> {
body.put("status", 409);
body.put("title", "SKU duplikat");
body.put("detail", e.getMessage());
}
case InsufficientStockException e -> {
body.put("status", 422);
body.put("title", "Stok tidak cukup");
body.put("detail", e.getMessage());
body.put("requested", e.requested());
body.put("available", e.available());
}
default -> {
body.put("status", 500);
body.put("title", "Terjadi kesalahan internal");
body.put("detail", "Silakan coba lagi nanti"); // pesan asli TIDAK dikirim ke klien
}
}
body.put("instance", path);
return body;
}
public static void main(String[] args) {
List<RuntimeException> errors = List.of(
new ProductNotFoundException("KOPI-999"),
new DuplicateSkuException("KOPI-250"),
new InsufficientStockException("TEH-100", 5, 2),
new IllegalStateException("koneksi pool habis: jdbc:postgresql://10.0.0.5/shop"));
for (RuntimeException ex : errors) {
System.out.println(toProblem(ex, "/api/products"));
}
}
}Output:
text
{status=404, title=Produk tidak ditemukan, detail=Produk tidak ditemukan: KOPI-999, instance=/api/products}
{status=409, title=SKU duplikat, detail=SKU sudah terdaftar: KOPI-250, instance=/api/products}
{status=422, title=Stok tidak cukup, detail=Stok TEH-100 tidak cukup, requested=5, available=2, instance=/api/products}
{status=500, title=Terjadi kesalahan internal, detail=Silakan coba lagi nanti, instance=/api/products}Perhatikan baris terakhir: pesan IllegalStateException berisi alamat database, tetapi yang keluar hanya pesan umum. Konvensi status yang dipakai di kurikulum ini:
| Situasi | Status | Contoh exception |
|---|---|---|
| JSON rusak, field wajib kosong, format salah | 400 | MethodArgumentNotValidException |
| Resource yang diminta tidak ada | 404 | ProductNotFoundException |
| Bertentangan dengan state resource saat ini | 409 | DuplicateSkuException |
| Request valid secara format, tetapi melanggar aturan | 422 | InsufficientStockException |
| Bug atau kegagalan infrastruktur yang tidak kita duga | 500 | NullPointerException, timeout |
Sebagian tim memakai 409 juga untuk stok kurang. Keduanya bisa dibela — yang tidak bisa dibela adalah inkonsisten antarendpoint.
@RestControllerAdvice + @ExceptionHandler
@RestControllerAdvice menandai bean yang berlaku untuk semua controller. Method @ExceptionHandler di dalamnya dipanggil saat controller (atau service yang dipanggilnya) melempar exception yang cocok. Bila method mengembalikan ProblemDetail, status HTTP diambil dari properti status-nya.
java
// Potongan: aplikasi Spring Boot 4.1 (Spring Framework 7)
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
static final String PROBLEM_BASE = "https://api.tokoku.id/problems/";
@ExceptionHandler(ProductNotFoundException.class)
ProblemDetail handleNotFound(ProductNotFoundException ex) {
return problem(HttpStatus.NOT_FOUND, "product-not-found", "Produk tidak ditemukan", ex.getMessage());
}
@ExceptionHandler(DuplicateSkuException.class)
ProblemDetail handleDuplicate(DuplicateSkuException ex) {
return problem(HttpStatus.CONFLICT, "duplicate-sku", "SKU sudah terdaftar", ex.getMessage());
}
static ProblemDetail problem(HttpStatus status, String slug, String title, String detail) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail);
pd.setType(URI.create(PROBLEM_BASE + slug));
pd.setTitle(title);
return pd;
}
}Kenapa extends ResponseEntityExceptionHandler? Class bawaan itu sudah menangani semua exception milik Spring MVC — MethodArgumentNotValidException, HttpMessageNotReadableException (JSON rusak), NoResourceFoundException (URL tidak ada), HttpRequestMethodNotSupportedException (405), dan lainnya — dan merendernya sebagai ProblemDetail. Kamu cukup menambah handler untuk exception domain dan meng-override yang perlu dikustomisasi.
Bila beberapa handler cocok, Spring memilih yang tipenya paling spesifik, bukan yang ditulis lebih dulu. Controller pun tetap bersih: tidak ada try/catch, cukup memanggil service.
Error validasi: 400 dengan daftar field
Saat @Valid @RequestBody gagal, Spring melempar MethodArgumentNotValidException. Body bawaannya hanya mengatakan request tidak valid — klien tidak tahu field mana yang salah. Override handleMethodArgumentNotValid untuk menambahkan daftar field:
java
// Potongan: method tambahan di ApiExceptionHandler
record FieldViolation(String field, String message) {}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<FieldViolation> errors = ex.getFieldErrors().stream()
.map(fe -> new FieldViolation(fe.getField(), fe.getDefaultMessage()))
.toList();
ProblemDetail pd = ex.getBody(); // sudah berstatus 400
pd.setType(URI.create(PROBLEM_BASE + "validation-error"));
pd.setTitle("Request tidak valid");
pd.setDetail("Ada " + errors.size() + " field yang tidak valid");
pd.setProperty("errors", errors);
return handleExceptionInternal(ex, pd, headers, status, request);
}Dengan DTO CreateProductRequest dari bab sebelumnya (@NotBlank(message = "nama wajib diisi"), @PositiveOrZero(message = "harga tidak boleh negatif")), request yang salah menghasilkan:
json
{
"type": "https://api.tokoku.id/problems/validation-error",
"title": "Request tidak valid",
"status": 400,
"detail": "Ada 2 field yang tidak valid",
"instance": "/api/products",
"errors": [
{ "field": "name", "message": "nama wajib diisi" },
{ "field": "price", "message": "harga tidak boleh negatif" }
]
}Urutan isi errors tidak dijamin, jadi klien (dan test) jangan bergantung pada urutannya. Sengaja tidak ada rejectedValue: nilai yang ditolak bisa berupa password atau data pribadi. Constraint langsung di parameter method (mis. @PathVariable @Size(max = 20) String sku) memicu HandlerMethodValidationException, yang juga ditangani ResponseEntityExceptionHandler sebagai 400.
Konfigurasi Boot: problemdetails dan halaman /error
Tanpa advice sama sekali, Spring Boot merender error lewat endpoint /error dengan JSON bentuk lama (timestamp, status, error, path). Properti berikut mengubah perilakunya:
yaml
spring:
mvc:
problemdetails:
enabled: true # exception bawaan Spring MVC dirender sebagai application/problem+json
web:
error:
include-stacktrace: never # default-nya memang never — jangan diubah di production
include-message: neverspring.mvc.problemdetails.enabled=true mendaftarkan advice bawaan Boot (turunan ResponseEntityExceptionHandler). Advice itu mundur bila kamu sudah punya turunan sendiri seperti ApiExceptionHandler, jadi properti ini paling berguna untuk aplikasi kecil tanpa advice. Properti spring.web.error.* (di Boot 3 bernama server.error.*) tetap relevan untuk error yang lolos dari advice, misalnya yang terjadi di filter sebelum controller.
Error 500: pesan umum di luar, detail lengkap di log
Exception tak terduga (bug, database mati, timeout) tidak boleh mengirim pesan aslinya ke klien. Tangkap sisanya dengan handler Exception, kirim pesan umum, dan catat stack trace di log dengan id yang juga dikirim ke klien. Bila Micrometer Tracing aktif (dibahas di level Microservices), traceId sudah tersedia di MDC; kalau belum, buat UUID.
java
// Potongan: method tambahan di ApiExceptionHandler
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(Exception.class)
ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
String correlationId = MDC.get("traceId");
if (correlationId == null) {
correlationId = UUID.randomUUID().toString();
}
log.error("Unhandled error correlationId={} method={} path={}",
correlationId, request.getMethod(), request.getRequestURI(), ex); // argumen terakhir = stack trace
ProblemDetail pd = problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal-error",
"Terjadi kesalahan internal", "Silakan coba lagi. Sertakan correlationId saat menghubungi support.");
pd.setProperty("correlationId", correlationId);
return pd;
}Untuk 4xx cukup log level WARN/INFO tanpa stack trace (atau tidak sama sekali): itu kesalahan klien, bukan insiden. Log ERROR untuk setiap 404 hanya membuat alert berisik.
Contoh di dunia backend
Endpoint checkout memesan beberapa item sekaligus. Service melempar exception domain; controller tidak tahu apa-apa soal HTTP error. Handler stok kurang menambahkan field yang dibutuhkan frontend untuk menampilkan "tersisa 2".
java
// Potongan: controller tetap tipis — tidak ada try/catch
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest request) {
OrderResponse order = orderService.place(request); // bisa melempar InsufficientStockException
return ResponseEntity.created(URI.create("/api/orders/" + order.id())).body(order);
}
}
// Potongan: handler tambahan di ApiExceptionHandler
// (InsufficientStockException versi service ini juga menyimpan sku())
@ExceptionHandler(InsufficientStockException.class)
ProblemDetail handleStock(InsufficientStockException ex) {
ProblemDetail pd = problem(HttpStatus.UNPROCESSABLE_CONTENT, "insufficient-stock",
"Stok tidak cukup", ex.getMessage());
pd.setProperty("sku", ex.sku());
pd.setProperty("requested", ex.requested());
pd.setProperty("available", ex.available());
return pd;
}bash
curl -i -X POST localhost:8080/api/orders \
-H 'Content-Type: application/json' \
-d '{"items":[{"sku":"TEH-100","quantity":5}]}'text
HTTP/1.1 422
Content-Type: application/problem+json
{"type":"https://api.tokoku.id/problems/insufficient-stock","title":"Stok tidak cukup","status":422,
"detail":"Stok TEH-100 tidak cukup","instance":"/api/orders","sku":"TEH-100","requested":5,"available":2}HttpStatus.UNPROCESSABLE_CONTENT adalah nama baru di Spring Framework 7 sesuai RFC 9110; UNPROCESSABLE_ENTITY masih ada tetapi sudah deprecated. Frontend cukup memeriksa type yang berakhiran insufficient-stock lalu membaca available — tanpa mem-parsing teks.
Pertanyaan interview
1. Kenapa error handling sebaiknya terpusat di @RestControllerAdvice, bukan try/catch di setiap controller?
Terpusat menjamin format dan status yang konsisten di seluruh API, menghapus duplikasi, dan membuat controller fokus pada alur sukses. Menambah jenis error baru cukup di satu tempat. try/catch per controller cepat menjadi tidak seragam — satu endpoint mengembalikan 400, endpoint lain 500 untuk kasus yang sama.
2. Apa itu RFC 9457 dan apa keuntungannya dibanding format error buatan sendiri?
Standar body error HTTP (application/problem+json) dengan field type, title, status, detail, instance, plus field tambahan. Keuntungannya: klien dan tooling sudah mengenal formatnya, dan Spring mendukungnya langsung lewat ProblemDetail. type memberi identitas stabil yang bisa dipakai klien untuk bercabang.
3. Bagaimana menangani error 500 dengan aman?
Jangan pernah mengirim stack trace atau pesan exception mentah ke klien — bisa berisi SQL, host, atau path internal. Kirim pesan umum plus correlation id, lalu log stack trace lengkap dengan id yang sama di level ERROR. Dengan begitu klien bisa melapor, tim bisa menelusuri, dan penyerang tidak mendapat informasi.
Jebakan umum
- Mengembalikan
ex.getMessage()dari exception tak dikenal. PesanSQLExceptionatauNullPointerExceptionbisa membocorkan detail internal. Hanya exception domain yang pesannya memang ditulis untuk klien. - Status
200dengan body{"success": false}. Klien, cache, dan monitoring membaca status HTTP — error harus memakai status4xx/5xx. - Handler
Exceptiontanpa log. Error tertelan diam-diam; saat insiden tidak ada jejak sama sekali. - Memakai
400untuk semuanya. Klien tidak bisa membedakan "input salah" dari "tidak ditemukan" atau "konflik". - Membuat advice sendiri tanpa
extends ResponseEntityExceptionHandlerlalu menangkapException: error bawaan seperti405atau JSON rusak ikut menjadi500. - Mengirim
rejectedValuedi error validasi — bisa berisi password atau data pribadi.
Ringkasan
- RFC 9457 menstandarkan body error:
type,title,status,detail,instance+ field tambahan, dengan media typeapplication/problem+json; di Spring dimodelkan olehProblemDetail. @RestControllerAdviceyangextends ResponseEntityExceptionHandlermemusatkan semua error: exception Spring MVC sudah ditangani, exception domain ditambahkan lewat@ExceptionHandler.- Konvensi: validasi
400, tidak ditemukan404, konflik state409, aturan bisnis422, tak terduga500. - Override
handleMethodArgumentNotValiduntuk menyertakan daftar field yang salah. spring.mvc.problemdetails.enabledberguna bila belum ada advice sendiri; stack trace jangan pernah dikirim.- Error
500→ pesan umum + correlation id ke klien, stack trace lengkap ke log.