Xử lý Exception
Xem thửMột API thanh toán trả về 500 kèm thân JSON có trường message chứa chuỗi kết nối cơ sở dữ liệu của hệ thống (jdbc:postgresql://db:5432). Đội bảo mật phát hiện qua một báo cáo pentest. Nguyên nhân: ai đó đặt spring.web.error.include-message=always "để dễ debug" trên staging rồi bê nguyên cấu hình lên production. Cùng tháng, đội mobile phàn nàn vì hai endpoint trả hai định dạng lỗi khác nhau, một cái có error, một cái có message, và một endpoint trả lỗi từ bộ lọc xác thực bằng định dạng thứ ba mà @RestControllerAdvice không bao giờ chạm tới. Quản lý lỗi là nơi hợp đồng API thường lỏng nhất, vì mọi người nghĩ "lỗi thì sao cũng được".
Bài này đi vào cách Spring MVC và Spring Boot biến một exception thành phản hồi, từng lớp của chuỗi xử lý, vì sao lỗi trong filter đi một đường khác, và cách thiết kế một hợp đồng lỗi nhất quán bằng Problem Details (RFC 9457). Mọi hành vi trong bài đã được đo bằng server thật trên Spring Boot 4.1.1.
Sau bài này, bạn có thể
- Mô tả chuỗi
HandlerExceptionResolvervà thứ tự ưu tiên giữa@ExceptionHandler,@ResponseStatus,ResponseStatusExceptionvà các exception có sẵn của Spring MVC. - Dùng
@RestControllerAdvicekế thừaResponseEntityExceptionHandlerđể trảProblemDetailnhất quán, kể cả lỗi validation kèm danh sách trường. - Giải thích hai đường xử lý lỗi (trong MVC và ngoài MVC qua
/error) và đưa lỗi từ filter về cùng một định dạng. - Cấu hình
spring.web.error.*an toàn và biết những thuộc tính nào rò rỉ thông tin. - Thiết kế phân loại lỗi, chính sách ghi log và mã lỗi máy đọc được.
Một exception đi đâu
Cách đọc hình
Từ ô "Exception thrown" bên trái, exception được thử với ba resolver ở giữa theo thứ tự. Nhánh trên là @ExceptionHandler: nếu có handler phù hợp, bạn tự quyết định phản hồi (thường là ProblemDetail). Hai nhánh dưới (resolver của @ResponseStatus/ResponseStatusException và resolver mặc định của Spring MVC) chuyển exception thành status. Cuối cùng, nếu không resolver nào nhận, hoặc resolver chỉ đặt status mà không có thân, request rơi sang /error (BasicErrorController) để Spring Boot dựng thân JSON mặc định.
Cơ chế bên trong
Đang tải sơ đồ…
DispatcherServlet.processHandlerException duyệt danh sách HandlerExceptionResolver (gộp trong một composite) và dừng ở resolver đầu tiên trả kết quả khác null. Thứ tự mặc định:
ExceptionHandlerExceptionResolvertìm phương thức@ExceptionHandler. Nó thử các handler trong chính controller trước, rồi tới các@ControllerAdvicetheo@Order. Trong mỗi nơi, chọn handler có kiểu exception gần nhất trong cây kế thừa (ExceptionDepthComparator).ResponseStatusExceptionResolverxử lý exception gắn@ResponseStatusvàResponseStatusException.DefaultHandlerExceptionResolverđổi các exception nội bộ của Spring MVC thành status chuẩn: 405 choHttpRequestMethodNotSupportedException, 415 choHttpMediaTypeNotSupportedException, 406 choHttpMediaTypeNotAcceptableException, 400 cho thiếu tham số, sai kiểu, thân không đọc được, validation; 404 cho không có handler hay không có tài nguyên tĩnh; 503 khi hết thời gian xử lý bất đồng bộ.
Khi tất cả bỏ qua, exception được ném lại cho container, và vòng "error dispatch" gọi tới /error. Spring Boot đăng ký BasicErrorController và ErrorAttributes cho đường này.
@ExceptionHandler: quy tắc chọn và chữ ký
- Phạm vi: một
@ExceptionHandlertrong controller chỉ áp dụng cho controller đó; trong@ControllerAdvicethì toàn cục, có thể giới hạn bằngbasePackages,annotations,assignableTypes. - Kiểu gần nhất thắng: với
ProductNotFoundException extends RuntimeException, handler choProductNotFoundExceptionthắng handler choException. - Tham số được phép: chính exception,
WebRequest,HttpServletRequest,Locale,Principal... Giá trị trả về:ProblemDetail,ErrorResponse,ResponseEntity, hoặc object (với@RestControllerAdviceđược ghi bằng converter). - Một handler có thể xử lý nhiều kiểu:
@ExceptionHandler({A.class, B.class}).
@RestControllerAdvice
class ApiAdvice extends ResponseEntityExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
ProblemDetail notFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Product not found");
problem.setProperty("code", "PRODUCT_NOT_FOUND");
return problem;
}
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex) {
log.error("Unhandled exception", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
}
}
Kế thừa ResponseEntityExceptionHandler là điểm mấu chốt: lớp cơ sở có sẵn handler cho các exception của Spring MVC (405, 415, 400...) và đều trả ProblemDetail. Nếu bạn chỉ viết handler cho Exception, nó nuốt luôn cả những exception này và biến chúng thành 500, một lỗi rất phổ biến.
Problem Details (RFC 9457)
ProblemDetail biểu diễn lỗi với các trường chuẩn type, title, status, detail, instance cộng các trường mở rộng bạn thêm bằng setProperty. Phản hồi có Content-Type: application/problem+json. Kết quả đo:
| Tình huống | Có ResponseEntityExceptionHandler và advice | Chỉ bật spring.mvc.problemdetails.enabled=true | Mặc định của Boot |
|---|---|---|---|
ProductNotFoundException (do bạn ném) | 404 problem, code, title riêng | 500, thân mặc định | 500, thân mặc định |
ResponseStatusException | 404 problem | 404 problem | 404, thân mặc định |
| Thân JSON hỏng | 400 problem, "Failed to read request" | 400 problem, cùng nội dung | 400, thân mặc định |
| Sai phương thức | 405 problem | 405 problem | 405, thân mặc định |
Thuộc tính spring.mvc.problemdetails.enabled=true tự đăng ký một ResponseEntityExceptionHandler, nên đủ để chuẩn hóa các lỗi của Spring MVC, nhưng không động tới exception nghiệp vụ của bạn. Muốn lỗi nghiệp vụ cũng ở định dạng đó, cần advice của bạn.
Có hai cách để exception nghiệp vụ tự mang thông tin phản hồi mà không cần handler riêng: kế thừa ErrorResponseException:
public class OrderNotFoundException extends ErrorResponseException {
public OrderNotFoundException(long id) {
super(HttpStatus.NOT_FOUND,
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Order " + id + " not found"), null);
}
}
Với advice kế thừa ResponseEntityExceptionHandler, exception này cho 404 dạng application/problem+json. Nếu không có advice hay thuộc tính trên, nó vẫn cho 404 nhưng ở thân JSON mặc định của Boot.
Lỗi validation kèm danh sách trường
Mặc định MethodArgumentNotValidException chỉ cho "detail": "Invalid request content.", không liệt kê trường lỗi (cố ý, để không lộ chi tiết). Muốn trả danh sách, ghi đè handler và đưa vào một thuộc tính mở rộng:
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.putIfAbsent(error.getField(), error.getDefaultMessage());
}
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", errors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
Kết quả đo với một DTO sai nhiều chỗ: {"detail":"Validation failed","instance":"/api/users","status":400,"title":"Bad Request","errors":{"address.city":"must not be blank","tags[0]":"must not be blank","name":"size must be between 2 and 20", ...}}. Lưu ý đường dẫn trường (address.city, tags[0]) và rằng thứ tự các khóa không được bảo đảm.
Đang tải Animation…
Thử và quan sát
Chạy luồng lỗi nghiệp vụ và theo dõi: exception đi lên advice, được ánh xạ thành 404 với ProblemDetail, còn controller không có try/catch. Rồi bật lỗi bất ngờ: client chỉ nhận thông điệp chung và chi tiết nằm ở log máy chủ. Hãy đối chiếu từng bước với sơ đồ luồng phía trên.
Hai đường: trong MVC và ngoài MVC
Mọi thứ phía trên chỉ áp dụng cho exception ném bên trong DispatcherServlet. Exception ném từ filter (bộ lọc xác thực, giới hạn tốc độ, ghi log) xảy ra trước DispatcherServlet nên không @ControllerAdvice nào thấy. Kết quả đo: một filter ném IllegalStateException cho thân JSON mặc định của Boot ({"timestamp":...,"status":500,...}) ngay cả khi advice đã được cấu hình.
Hai cách đưa lỗi từ filter về cùng định dạng:
- Ủy thác cho resolver của MVC. Inject bean
handlerExceptionResolvervà gọi nó trong khốicatchcủa filter; advice của bạn sẽ xử lý:
@Component
class ResolvingFilter extends OncePerRequestFilter {
private final HandlerExceptionResolver resolver;
ResolvingFilter(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
try {
chain.doFilter(request, response);
} catch (ProductNotFoundException ex) {
resolver.resolveException(request, response, null, ex);
}
}
}
Thử nghiệm: exception ném từ filter nhờ cách này nhận đúng 404 với application/problem+json và code như ở controller.
- Tùy biến
/error. Cung cấpErrorAttributeshoặcErrorControllerriêng để mọi lỗi ngoài MVC cũng ra định dạng của bạn. Với Spring Security, đặtAuthenticationEntryPointvàAccessDeniedHandlerghiProblemDetail, vì lỗi bảo mật cũng xảy ra trong filter.
Cấu hình spring.web.error.*: rò rỉ thông tin
Boot dựng thân lỗi mặc định từ ErrorAttributes; mặc định chỉ gồm timestamp, status, error, path. Các thuộc tính include-message, include-binding-errors, include-stacktrace, include-exception mặc định là never. Thử nghiệm với spring.web.error.include-message=always: thân lỗi 500 bây giờ chứa "message":"secret internal detail: jdbc:postgresql://db:5432". Đó chính là nguyên nhân của sự cố mở bài. Quy tắc: không bao giờ bật always trên môi trường có người dùng thật; nếu cần thông điệp, trả qua advice với nội dung đã duyệt.
Thiết kế hợp đồng lỗi
- Phân loại. Lỗi nghiệp vụ dự kiến (không tìm thấy, vi phạm quy tắc) dùng exception không checked có tên rõ nghĩa, ánh xạ sang 4xx và
codemáy đọc được. Lỗi hệ thống không lường trước ánh xạ về 500 với thông điệp chung và mã truy vết. - Mã lỗi ổn định. Trường
code(PRODUCT_NOT_FOUND) là hợp đồng cho client lập trình;detaillà văn bản cho người đọc và có thể đổi. - Mã truy vết. Thêm
traceIdvàoProblemDetailđể người dùng báo lỗi và đội vận hành tìm log. - Ghi log đúng chỗ, một lần. 5xx: ERROR kèm stack trace ở advice. 4xx do client: INFO hoặc WARN, không stack trace. Đừng vừa log trong service, vừa trong advice, vừa trong filter.
- Thông điệp riêng cho
type. Với API công khai, chotypelà URL tới tài liệu mô tả lỗi.
Exception và transaction
Exception unchecked thoát khỏi phương thức @Transactional làm giao dịch rollback trước khi tới advice. Exception sinh ra lúc commit (ví dụ vi phạm ràng buộc duy nhất) được ném từ proxy khi phương thức service đã trả về; nó vẫn đi lên controller và advice, nên ánh xạ DataIntegrityViolationException sang 409 ở advice vẫn đúng. Nếu service bắt exception và cố tiếp tục sau khi giao dịch đã bị đánh dấu rollback-only, commit sẽ ném UnexpectedRollbackException: không "nuốt" exception trong khối giao dịch.
Bài thực hành
- Dựng
DemoControllerném các exception: không lường trước, nghiệp vụ,ResponseStatusException, thân sai và tham số vi phạm ràng buộc. Chạy bằng server thật và ghi lại bảng trạng thái vàContent-Typeở ba chế độ: mặc định, chỉ bậtspring.mvc.problemdetails.enabled=true, và có advice kế thừaResponseEntityExceptionHandler. - Thêm filter ném exception nghiệp vụ; ghi lại thân phản hồi, rồi chuyển hướng qua
handlerExceptionResolvervà so sánh. - Bật
spring.web.error.include-message=alwaystrên một bản sao và chứng minh thông điệp bị lộ; sau đó tắt và xác nhận. - Ghi đè handler validation để trả danh sách trường, kể cả lỗi tham số (
HandlerMethodValidationException).
Tiêu chí hoàn thành: bảng ba chế độ khớp với bảng trong bài; lỗi từ filter chỉ ra được hai định dạng khác nhau và được thống nhất sau bước 2; bước 3 có dòng thân chứa thông điệp nhạy cảm; bước 4 trả errors có tên trường, cả cho @RequestBody lẫn @RequestParam. Gợi ý: nếu endpoint tham số trả thân khác với endpoint body, bạn đã quên ghi đè handleHandlerMethodValidationException.
Sự cố thường gặp
- 405 và 415 biến thành 500. Nguyên nhân: advice bắt
Exceptionmà không kế thừaResponseEntityExceptionHandler. Sửa: kế thừa lớp cơ sở. - Hai định dạng lỗi trong một API. Nguyên nhân: lỗi filter và lỗi controller đi hai đường. Sửa: ủy thác cho resolver hoặc tùy biến
/error. - Lộ thông tin trong thân lỗi. Nguyên nhân:
include-message,include-stacktrace, hoặc trảex.getMessage()của lỗi hệ thống. Sửa: thông điệp chung cộng mã truy vết. @Validatedở class controller cho 500. Nguyên nhân: kiểm tra đi qua AOP và némConstraintViolationException; đã đo trên server thật. Sửa: bỏ@Validatedkhỏi controller, hoặc xử lý exception đó.- Log đầy stack trace cho lỗi 404. Nguyên nhân: ghi ERROR cho lỗi client. Sửa: phân mức log theo loại lỗi.
- Client không phân biệt được lỗi để thử lại. Nguyên nhân: không có
codevàRetry-After. Sửa: thêm mã ổn định; 503 và 429 kèmRetry-After.
Bảo mật và vận hành
- Thông tin lỗi là bề mặt trinh sát: stack trace, tên lớp, SQL, đường dẫn tệp, phiên bản thư viện đều có giá trị với kẻ tấn công.
- Theo dõi
http.server.requeststheostatusvàoutcome; cảnh báo theo tỷ lệ 5xx, không theo số lượng tuyệt đối. - Đặt mã truy vết vào cả log lẫn phản hồi; không ghi thân yêu cầu có dữ liệu nhạy cảm khi lỗi.
- Với 5xx lặp lại, ngắt mạch ở phía gọi thay vì để mỗi lần thử đều tạo stack trace.
Tự kiểm tra
- Thứ tự ba resolver mặc định là gì và vì sao
@ExceptionHandlerluôn được thử trước? - Vì sao advice chỉ có
@ExceptionHandler(Exception.class)làm 405 thành 500, và sửa thế nào? - Vì sao exception ném từ filter không tới
@RestControllerAdvice? Nêu hai cách đưa về cùng định dạng. spring.mvc.problemdetails.enabled=truelàm gì và không làm gì vớiProductNotFoundExceptioncủa bạn?spring.web.error.include-message=alwaysgây rủi ro gì, và cách cung cấp thông điệp an toàn là gì?- Một lỗi vi phạm ràng buộc duy nhất xảy ra lúc commit có tới được advice không?
Gợi ý đáp án. (1) ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver; resolver đầu cho phép bạn kiểm soát phản hồi nên được ưu tiên. (2) Handler cho Exception khớp cả HttpRequestMethodNotSupportedException; kế thừa ResponseEntityExceptionHandler để các exception của MVC được xử lý đúng. (3) Filter chạy trước DispatcherServlet; ủy thác handlerExceptionResolver hoặc tùy biến ErrorAttributes, ErrorController. (4) Chuẩn hóa lỗi của Spring MVC sang problem+json; không đụng tới exception nghiệp vụ. (5) Lộ thông điệp nội bộ (như chuỗi kết nối); trả thông điệp đã duyệt qua advice. (6) Có: exception ném từ proxy sau khi phương thức trả về và đi lên controller rồi advice.
Điều cần nhớ
- Chuỗi resolver:
@ExceptionHandler, rồi@ResponseStatus, rồi mặc định của MVC, rồi/error. - Kế thừa
ResponseEntityExceptionHandlervà trảProblemDetailđể có hợp đồng lỗi nhất quán. - Lỗi trong filter đi đường khác: ủy thác hoặc tùy biến
/error. - Không để thông tin nội bộ rò rỉ; mặc định
neverlà đúng. - Mã lỗi ổn định, mã truy vết và log đúng mức.
Học liệu chọn lọc
- Spring Framework Reference: Error Responses
- Spring Framework Reference: @ExceptionHandler
- Spring Framework Reference: Exceptions (HandlerExceptionResolver)
- Spring Boot Reference: Error Handling
- RFC 9457: Problem Details for HTTP APIs
- OWASP: Error Handling Cheat Sheet
- The Jakarta EE Tutorial: Servlets, error handling
- Mã nguồn nên đọc: DispatcherServlet.processHandlerException, ExceptionHandlerExceptionResolver, ResponseEntityExceptionHandler, BasicErrorController.