Thiết kế phản hồi lỗi theo RFC 9457 để đội vận hành của khách tự xử lý sự cố
Nếu API chỉ trả về một mã 500 kèm câu "Something went wrong", sự cố nào bên khách cũng sẽ thành ticket gửi về bạn. Vài trường JSON đặt đúng chỗ có thể thay đổi điều đó.
Cùng một sự cố, nhưng phản hồi có cấu trúc giúp khách tự xử lý thay vì mở ticket gửi về bạn.
Đồ hoạ: FDE Times
Tóm tắt nhanh
- RFC 9457 đã thay RFC 7807 và là chuẩn hiện hành cho phản hồi lỗi dạng application/problem+json.
- Máy phân loại lỗi bằng type, người đọc detail, còn đội vận hành dùng instance để tra log. Đừng bao giờ bắt client parse detail.
- Thêm trường mở rộng như runbook hay retryable để khách tự xử lý, và không đưa stack dump vào phản hồi.
Thử hình dung tuần đầu sau khi bạn bàn giao một integration cho khách. Lúc nửa đêm, dashboard của đội vận hành bên họ chuyển sang đỏ. Trong log chỉ có một dòng 500 Internal Server Error và body là {"error": "Something went wrong"}.
Người trực ca bên khách không biết lỗi nằm ở hệ thống của họ hay của bạn, không biết có nên thử lại không, cũng không biết phải tra log ở đâu. Thế là họ mở ticket. Sáng hôm sau bạn có mười ticket, và ticket nào cũng bắt đầu bằng câu “API của anh lỗi”.
Với một FDE, phản hồi lỗi cũng là một phần của bàn giao, không chỉ là chi tiết kỹ thuật. Thiết kế tốt thì khách tự xử lý được phần lớn sự cố. Thiết kế kém thì bạn thành người trực ca vĩnh viễn cho hệ thống của người khác.
Mã trạng thái chỉ kể được nửa câu chuyện
RFC 9457 ra đời từ một nhận xét rất thực tế: chỉ một mã trạng thái HTTP thường không đủ để người nhận hiểu chuyện gì đã xảy ra. Mã 422 cho biết request có vấn đề. Nó không nói vấn đề là gì, xảy ra với tài khoản nào, và người nhận nên làm gì tiếp theo.
Để lấp chỗ trống đó, RFC định nghĩa một định dạng JSON với media type riêng là application/problem+json. Bạn có thể đã nghe đến RFC 7807 từ năm 2016. RFC 9457 đã chính thức thay thế văn bản đó, nên khi viết tài liệu hay trao đổi với kiến trúc sư bên khách, hãy dẫn RFC 9457.
Điểm hay của chuẩn này là mỗi trường được viết cho một đối tượng đọc riêng. Bảng dưới đây xếp các trường theo câu hỏi mà người trực ca hay đặt ra:
| Trường | Ai đọc | Trả lời câu hỏi |
|---|---|---|
type |
Máy của khách | Đây là loại lỗi nào, để xử lý tự động? |
title |
Người | Tóm tắt ngắn: loại lỗi này là gì? |
detail |
Người | Lần này cụ thể đã xảy ra chuyện gì? |
instance |
Đội vận hành | Lần lỗi này mang mã gì để tra log và đối chiếu? |
| Trường mở rộng | Cả hai | Tài khoản nào bị ảnh hưởng, xử lý theo hướng dẫn nào? |
Ví dụ: API đẩy đơn sang kho
Giả sử bạn tích hợp hệ thống đặt hàng của một chuỗi bán lẻ với API kho của công ty bạn. Một tài khoản đã gửi hết hạn mức đơn trong ngày. Phiên bản kém sẽ trả về như sau:
{ "error": "Bad request", "code": 4021 }
Người trực ca không biết 4021 nghĩa là gì, nên lại mở ticket. Còn đây là phiên bản theo RFC 9457:
{
"type": "https://docs.example.vn/problems/vuot-han-muc-don",
"title": "Vượt hạn mức đơn trong ngày",
"detail": "Tài khoản KH-0192 đã dùng hết hạn mức đơn hôm nay. Hạn mức được đặt lại lúc 00:00.",
"instance": "/incidents/7f3a2c",
"account_id": "KH-0192",
"runbook": "https://docs.example.vn/runbook/han-muc-don",
"retryable": false
}
Đọc phiên bản này, người trực ca biết ngay lỗi nằm ở phía họ và biết khi nào lỗi tự hết. Họ có đường dẫn đến hướng dẫn xử lý, và nếu vẫn cần gọi bạn thì có sẵn mã 7f3a2c để bạn tìm đúng dòng log. account_id, runbook và retryable là các trường mở rộng.
RFC cho phép thêm loại trường này và yêu cầu client bỏ qua những trường nó không nhận ra, nên bạn có thể bổ sung dần mà không làm hỏng client cũ.
Trường detail ở đây chỉ nêu vấn đề và cách khắc phục. Postman cũng khuyên thông điệp lỗi chỉ nên chứa đúng hai thứ đó. Tên bảng trong database và tên class Java không có ích gì cho người trực ca bên khách.
Để máy của khách cũng tự xử lý được
Người trực ca chỉ là một nửa. Nửa còn lại là code phía khách gọi API của bạn, và code đó cần biết rẽ nhánh dựa vào đâu.
RFC 7807 đã quy định rõ: client phải dùng type làm định danh chính cho loại lỗi và không nên parse chuỗi detail để lấy thông tin. Lý do nằm ngay trong ví dụ trên. Chuỗi detail dành cho người đọc, nên tuần sau bạn có thể sửa câu chữ, chuyển sang tiếng Anh hay thêm số liệu.
Nếu code của khách đang dùng regex để đọc chuỗi đó, nó sẽ hỏng âm thầm. Đoạn code minh hoạ dưới đây cho thấy phía khách nên rẽ nhánh thế nào với phản hồi ở ví dụ kho hàng:
PROBLEMS = "https://docs.example.vn/problems/"
def xu_ly_loi(resp, method):
if not resp.headers.get("Content-Type", "").startswith("application/problem+json"):
return mo_ticket(None)
problem = resp.json()
if problem.get("type") == PROBLEMS + "vuot-han-muc-don":
return doi_den_ngay_mai(problem.get("account_id"))
if problem.get("retryable") is True and method != "POST":
return thu_lai_sau()
return mo_ticket(problem.get("instance"))
Không dòng nào trong hàm này đọc detail. Bạn đổi câu chữ bao nhiêu lần, hàm vẫn chạy đúng. Còn khi phải mở ticket, nó gửi kèm instance để hai bên tìm đúng sự cố.
Cờ retryable trả lời câu hỏi mà khách nào cũng hỏi: có nên thử lại không. Cần nói rõ, retryable không có trong RFC. Đây là trường mở rộng do bạn tự đặt ra, nên bạn phải định nghĩa ý nghĩa của nó trong tài liệu, ngay tại trang mà type trỏ tới.
Một bài trên Hackernoon về retry khuyên chỉ thử lại với một số mã lỗi tạm thời, và thường nên bỏ qua POST để tránh tạo bản ghi trùng. Trong ví dụ kho hàng, nếu code của khách tự gửi lại một POST tạo đơn sau lỗi timeout, kho có thể nhận hai đơn giống hệt nhau, vì timeout không cho biết đơn đầu đã được ghi hay chưa.
Vì thế lời khuyên chỉ trọn vẹn khi đi kèm idempotency key. Nếu khách thật sự cần thử lại POST, hãy thiết kế để client sinh một khóa riêng cho mỗi đơn và gửi lại đúng khóa đó ở mọi lần thử. Server thấy khóa đã xử lý thì trả về kết quả cũ thay vì tạo đơn thứ hai.
Khi chưa có cơ chế này, retryable của mọi lỗi trên POST tạo đơn nên là false.
Làm được trong một buổi chiều với Spring
Nếu API của bạn viết bằng Java/Spring, phần lớn công việc đã có sẵn. Spring Framework hỗ trợ RFC 9457 qua lớp ProblemDetail. Spring Boot có property spring.mvc.problemdetails.enabled để các exception có sẵn tự trả về problem details:
spring.mvc.problemdetails.enabled=true
Với lỗi nghiệp vụ của riêng bạn, bạn tự dựng ProblemDetail trong exception handler:
@ExceptionHandler(QuotaExceededException.class)
ProblemDetail handleQuota(QuotaExceededException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
pd.setType(URI.create("https://docs.example.vn/problems/vuot-han-muc-don"));
pd.setTitle("Vượt hạn mức đơn trong ngày");
pd.setProperty("account_id", ex.getAccountId());
pd.setProperty("runbook", "https://docs.example.vn/runbook/han-muc-don");
pd.setProperty("retryable", false);
return pd;
}
Phần khó không nằm ở code. Phần khó là ngồi với đội vận hành của khách để liệt kê những lỗi họ thực sự gặp, đặt tên type cho từng lỗi và viết runbook cho mỗi lỗi. Việc này giống customer discovery hơn là lập trình.
Những cái bẫy hay gặp
Bẫy nguy hiểm nhất là để lộ chi tiết triển khai. RFC 9457 cảnh báo riêng về việc đưa những thứ như stack dump ra qua giao diện HTTP. Hãy ghi stack trace vào log nội bộ gắn với instance, còn phản hồi trả cho khách chỉ chứa mã đó.
Bẫy thứ hai là mỗi endpoint trả lỗi theo một kiểu: endpoint này dùng error, endpoint kia dùng message, một endpoint khác trả HTML. Postman khuyên phản hồi lỗi phải có cấu trúc rõ ràng và nhất quán. Chỉ cần một ngoại lệ là code phía khách phải viết thêm nhánh xử lý riêng.
Bẫy thứ ba là type trỏ đến một URI không có gì. Người trực ca lúc nửa đêm sẽ bấm vào đường dẫn đó. Nếu nó dẫn đến trang 404, bạn vừa mất cơ hội để họ tự xử lý.
Câu trả lời cho “bạn đã giảm ticket sau bàn giao thế nào?”
Trong phỏng vấn FDE, một câu hỏi kiểu “bạn đã làm gì để giảm ticket sau khi bàn giao?” là chỗ tốt để kể kỹ năng này. Đừng trả lời “tôi xử lý lỗi cẩn thận”; hãy kể đúng chuỗi việc: chuẩn hoá lỗi theo RFC 9457, viết runbook cho từng type, và đội vận hành bên khách tự đóng được những ticket trước đây phải chuyển về bạn.
Lần tới khi một lỗi nửa đêm được bên khách tự xử lý mà không ai phải gọi bạn, đó là dấu hiệu bạn đã thiết kế phản hồi lỗi đúng.