Problem Details RFC 9457: Chuẩn Hóa Phản Hồi Lỗi REST
Tìm hiểu chuẩn RFC 9457 với 5 trường cốt lõi, áp dụng lớp ProblemDetail trong Spring 6 và xây dựng phản hồi lỗi an toàn không lộ stack trace.
TL;DR: Trước khi RFC 9457 (kế thừa RFC 7807) ra đời, mỗi nhóm lập trình tự định nghĩa cấu trúc JSON báo lỗi theo cách riêng, khiến các ứng dụng client gặp nhiều khó khăn khi tích hợp. RFC 9457 giải quyết triệt để tình trạng phân mảnh này bằng cách quy chuẩn 5 trường thông tin chuẩn: type, title, status, detail, và instance kèm Media Type application/problem+json. Từ Spring 6 (Spring Boot 3), lớp ProblemDetail được tích hợp sẵn vào core framework. Về mặt an ninh, toàn bộ stack trace phải được giữ lại ở log máy chủ và liên kết qua requestId, tuyệt đối không gửi chi tiết nhạy cảm về trình duyệt client.
1. Tại sao chuẩn hóa lỗi theo RFC 9457 lại quan trọng trong REST API?
Khi phát triển hệ thống nhiều dịch vụ hoặc phối hợp đa nhóm, việc thiếu chuẩn chung dẫn đến cấu trúc lỗi phân mảnh:
// Doi A:
{ "error": "not_found", "msg": "Don hang 42 khong ton tai" }
// Doi B:
{ "success": false, "message": "Khong tim thay", "code": 404 }
// Doi C:
{ "status": 404, "errorMessage": "Not found", "errorCode": "E004" }
Client (Web, Mobile app, đối tác bên ngoài) buộc phải viết hàng loạt bộ phân tích cú pháp (parser) riêng cho từng API. Khi backend thay đổi tên trường, ứng dụng client lập tức bị đổ vỡ.

RFC 9457 (chuẩn IETF chính thức năm 2023) xác lập một hợp đồng giao tiếp duy nhất cho toàn bộ hệ thống API.
2. Năm trường thông tin cốt lõi của RFC 9457 mang ý nghĩa gì?
RFC 9457 định nghĩa tài liệu lỗi dạng JSON gồm 5 trường thuộc tính:
| Trường | Bắt buộc | Kiểu dữ liệu | Ý nghĩa |
|---|---|---|---|
type | Không | URI | Định danh loại lỗi (URI trang tài liệu hướng dẫn). Mặc định là "about:blank". |
title | Không | String | Tóm tắt ngắn gọn loại lỗi, mang tính bất biến (không thay đổi theo từng request). |
status | Bắt buộc | Integer | Mã trạng thái HTTP (khớp với mã ở HTTP response header). |
detail | Không | String | Giải thích cụ thể lý do lỗi xảy ra cho phiên request hiện tại. |
instance | Không | URI | Định danh phiên lỗi cụ thể, thường là URI của request (/api/v1/orders/42). |
Ngoài 5 trường chuẩn, RFC 9457 cho phép tự do bổ sung các trường mở rộng (extension members) như requestId, timestamp, violations.
Cấu trúc phản hồi lỗi mẫu chuẩn RFC 9457
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://api.olhub.org/errors/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "Order 42 was not found or has been deleted",
"instance": "/api/v1/orders/42",
"orderId": 42,
"timestamp": "2026-06-09T08:00:00Z"
}
3. Lớp ProblemDetail trong Spring 6 vận hành bên dưới ra sao?
Spring Framework 6 đưa lớp ProblemDetail vào nhân framework. Khi @RestControllerAdvice trả về một đối tượng ProblemDetail:
- Jackson serialize toàn bộ 5 trường chuẩn cùng các extension properties được gán qua
setProperty(). - Spring MVC tự động gán header
Content-Type: application/problem+json. - Mã HTTP status trên response header được đồng bộ trực tiếp từ trường
statuscủaProblemDetail.
Để áp dụng chuẩn này cho cả các lỗi mặc định của Spring MVC (như sai định dạng tham số, thiếu body), ta bật cờ cấu hình:
spring:
mvc:
problemdetails:
enabled: true
Sử dụng ProblemDetail Fluent API
// Khoi tao voi status HTTP
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
// Thiet lap cac truong chuan
pd.setType(URI.create("https://api.olhub.org/errors/order-not-found"));
pd.setTitle("Order not found");
pd.setDetail("Don hang 42 khong ton tai trong he thong");
pd.setInstance(URI.create("/api/v1/orders/42"));
// Bo sung cac extension member tuy bien
pd.setProperty("orderId", 42L);
pd.setProperty("timestamp", Instant.now());
pd.setProperty("requestId", MDC.get("requestId"));
4. Làm thế nào để cấu hình GlobalExceptionHandler trả về ProblemDetail?
Xây dựng @RestControllerAdvice tập trung để chuyển hóa toàn bộ domain exception sang ProblemDetail:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 1. Xu ly loi khong tim thay tai nguyen -> 404
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(OrderNotFoundException ex, HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setType(URI.create("https://api.olhub.org/errors/order-not-found"));
pd.setTitle("Order not found");
pd.setDetail(ex.getMessage());
pd.setInstance(URI.create(req.getRequestURI()));
pd.setProperty("orderId", ex.getOrderId());
return pd;
}
// 2. Xu ly xung dot du lieu -> 409
@ExceptionHandler(DuplicateOrderException.class)
public ProblemDetail handleDuplicateOrder(DuplicateOrderException ex, HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Duplicate order");
pd.setDetail(ex.getMessage());
pd.setInstance(URI.create(req.getRequestURI()));
return pd;
}
// 3. Xu ly loi validation du lieu dau vao -> 400
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setType(URI.create("https://api.olhub.org/errors/validation"));
pd.setTitle("Validation failed");
pd.setDetail("Du lieu yeu cau khong hop le");
pd.setInstance(URI.create(req.getRequestURI()));
List<Map<String, Object>> violations = ex.getBindingResult().getFieldErrors().stream()
.map(err -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("field", err.getField());
map.put("message", err.getDefaultMessage());
map.put("rejectedValue", err.getRejectedValue());
return map;
})
.toList();
pd.setProperty("violations", violations);
return pd;
}
// 4. Catch-all: An toan cho moi ngoai le chua luong truoc -> 500
@ExceptionHandler(Exception.class)
public ProblemDetail handleAllExceptions(Exception ex, HttpServletRequest req) {
String requestId = MDC.get("requestId");
log.error("Unhandled error: path={}, requestId={}", req.getRequestURI(), requestId, ex);
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("Internal server error");
pd.setDetail("Da xay ra loi he thong. Vui long lien he quan tri vien.");
pd.setInstance(URI.create(req.getRequestURI()));
pd.setProperty("requestId", requestId);
return pd;
}
}
5. Vì sao không bao giờ được phép để lộ stack trace ra môi trường Production?
Để lộ dấu vết thực thi (stack trace) hoặc thông báo lỗi nguyên bản từ hệ thống tạo điều kiện thuận lợi cho kẻ tấn công:
- Lộ cấu trúc nội bộ: Tiết lộ tên package, class nội bộ và luồng gọi method.
- Lộ phiên bản thư viện: Tên thư viện như
org.hibernate.orm:6.6.0giúp hacker tra cứu ngay các lỗ hổng đã biết trong cơ sở dữ liệu CVE. - Lộ cấu trúc cơ sở dữ liệu: Các lỗi như
Table 'orders_db.users' doesn't existtiết lộ tên bảng và trường dữ liệu.
Giải pháp xử lý chuẩn doanh nghiệp
- Ghi toàn bộ vết ngoại lệ và stack trace vào log server (ELK, Loki) kèm một định danh duy nhất
requestId. - Trả về cho client thông báo thân thiện kèm
requestId. Khi người dùng phản hồi sự cố, đội ngũ vận hành chỉ cần tra cứu theo mã này trên dashboard giám sát.
# application-prod.yml: Cau hinh an toan
server:
error:
include-stacktrace: never
include-message: never
include-binding-errors: never
6. Khi nào nên kế thừa ResponseEntityExceptionHandler của Spring?
Spring MVC cung cấp lớp cơ sở ResponseEntityExceptionHandler chứa sẵn mã xử lý cho hơn 20 ngoại lệ built-in của framework.
- Nên kế thừa khi: Bạn muốn tận dụng sẵn luồng xử lý của Spring và chỉ override một số phương thức nhất định (ví dụ chèn thêm danh sách
violationsvàoMethodArgumentNotValidExceptionthông quaex.getBody()). - Không cần kế thừa khi: Bạn muốn tự kiểm soát toàn bộ vòng đời tạo
ProblemDetailtừ đầu cho các ngoại lệ của riêng ứng dụng.
7. Làm sao để viết unit test và integration test cho Exception Handler?
Kiểm thử cơ chế xử lý lỗi quan trọng tương đương với kiểm thử luồng nghiệp vụ thành công. Dưới đây là cách kiểm thử toàn diện với @WebMvcTest:
@WebMvcTest(OrderController.class)
@Import(GlobalExceptionHandler.class)
class OrderExceptionHandlerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private OrderService orderService;
@Test
void whenOrderNotFound_thenReturns404ProblemDetail() throws Exception {
when(orderService.findById(99L)).thenThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/v1/orders/99"))
.andExpect(status().isNotFound())
.andExpect(content().contentType("application/problem+json"))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.title").value("Order not found"))
.andExpect(jsonPath("$.detail").value("Order 99 was not found"))
.andExpect(jsonPath("$.orderId").value(99));
}
@Test
void whenInternalError_thenDoesNotLeakStackTrace() throws Exception {
when(orderService.findById(any())).thenThrow(new RuntimeException("SQL syntax error at table users"));
String responseBody = mockMvc.perform(get("/api/v1/orders/1"))
.andExpect(status().isInternalServerError())
.andExpect(content().contentType("application/problem+json"))
.andExpect(jsonPath("$.requestId").exists())
.andReturn().getResponse().getContentAsString();
assertThat(responseBody).doesNotContain("SQL syntax");
assertThat(responseBody).doesNotContain("users");
assertThat(responseBody).doesNotContain("stackTrace");
}
}
8. Những cạm bẫy kỹ thuật thường gặp khi cấu hình Problem Details là gì?
- Quên bật cấu hình trong Spring Boot 3 cũ: Với Boot 3.0 đến 3.3, nếu không khai báo
spring.mvc.problemdetails.enabled=true, các lỗi framework vẫn trả về định dạng JSON cổ điển thay vì RFC 9457. - Khai báo trường
typetrỏ vào liên kết chết: RFC khuyến cáo trườngtypenên trỏ tới trang tài liệu có thật. Nếu chưa sẵn sàng, hãy để giá trị mặc định"about:blank"thay vì truyền URL trả về 404. - Đưa nhầm dữ liệu nhạy cảm vào
setProperty: Phương thức này nhận kiểuObjecttự do, cần tránh vô tình serialize các đối tượng chứa mật khẩu hoặc mã PIN của người dùng.
9. Problem Details kết nối với các bài học khác trong hệ thống ra sao?
- Exception Advice: Cung cấp kiến trúc định tuyến ngoại lệ 3 tầng; Problem Details đảm nhận vai trò chuẩn hóa dữ liệu đầu ra của tầng này.
- Jakarta Bean Validation: Vi phạm validation được bóc tách và nhúng trực tiếp vào trường
violationscủaProblemDetail.
10. Những điểm then chốt cần ghi nhớ là gì?
- Chuẩn hóa lỗi API bằng định dạng RFC 9457 với 5 trường:
type,title,status,detail,instance. - Sử dụng header
Content-Type: application/problem+jsonđể phân định rõ ràng các tài liệu thông báo sự cố. - Khai thác lớp
ProblemDetailcủa Spring 6 kết hợpsetProperty()cho các trường mở rộng. - Giữ vững nguyên tắc an toàn: Không bao giờ trả về stack trace ra ngoài response; liên kết tra cứu lỗi qua
requestId. - Viết integration test với MockMvc để xác minh cả mã trạng thái, header Content-Type và tính toàn vẹn của payload.
11. Bài tập tự kiểm tra mức độ hiểu sâu
- Q1Theo đặc tả RFC 9457, trường thông tin nào sau đây là bắt buộc phải có trong tài liệu Problem Details?
- Q2Giá trị mặc định của trường type trong đối tượng ProblemDetail khi không được lập trình viên thiết lập thủ công là gì?
- Q3Header Content-Type nào được Spring MVC tự động thiết lập khi trả về đối tượng ProblemDetail từ controller?
- Q4Khi cần bổ sung thêm các trường tùy biến như 'requestId' hoặc mảng 'violations' vào ProblemDetail, lập trình viên sử dụng phương thức nào?
- Q5Mô hình xử lý lỗi an toàn nhất cho ngoại lệ mã 500 trên môi trường Production là gì?
Bài tiếp theo: Jakarta Bean Validation — constraints
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
@RestControllerAdvice: Xử Lý Ngoại Lệ Tập Trung Toàn Cục
Bài tiếpJakarta Bean Validation: Kiểm Thực Khai Báo Với @Valid
Lộ trình Junior Java Backend Developer
Chặng 15/21 · REST API với Spring MVC — bài 15/21 của chặng
Thảo luận & góp ý
Hỏi đáp về bài này