Hai phần trước nói về chuyện API trả đúng dữ liệu. Phần này nói về lúc nó không trả được: client gửi JSON hỏng, thiếu trường, email trùng, hoặc database chết. Cách API diễn đạt những tình huống đó là một phần của hợp đồng — không kém phần thân phản hồi lúc thành công — và nó quyết định client sẽ làm gì tiếp theo: hiện lỗi cho người dùng, gửi lại, hay bỏ cuộc.
Cách thường gặp nhất để viết sai hợp đồng lỗi là không viết nó: if err != nil { http.Error(w, err.Error(), 500) }. Bài này đo xem dòng đó tốn bao nhiêu.
Ba cách trả cùng một lỗi

Hình 1: Bản A và B mỗi bản một dòng. Bản C dài hơn vì nó làm việc mà hai bản kia bỏ qua: phân loại lỗi trước khi chọn mã.
Cả ba bản dùng cùng một hàm nghiệp vụ taoKhach: kiểm tra email, kiểm tra tuổi, chèn vào PostgreSQL với ràng buộc UNIQUE trên email. Khác nhau chỉ ở chỗ handler làm gì với err:
- Bản A trả
500và nguyên vănerr.Error(). Đây là bản mặc định của rất nhiều codebase, vì nó là thứ ngắn nhất có thể viết. - Bản B luôn trả
200, lỗi nằm trong{"success": false, "message": ...}. Thường thấy ở API dựng cho một frontend duy nhất. - Bản C theo RFC 9457 Problem Details:
Content-Type: application/problem+json, thân cótype(định danh ổn định cho loại lỗi),title,status, và mở rộngerrorsliệt kê từng trường sai. Mã HTTP chọn theo ai gây ra lỗi: JSON hỏng là 400, dữ liệu sai là 422, email trùng là 409, và chỉ lỗi server mới là 500 — kèm mã tra cứu để đối chiếu với log, không kèmerr.Error().
Đo thật: 1.900 request thay vì 1.000
Phía client là một hàm gọi có chính sách retry phổ biến nhất hiện nay: retry khi 5xx hoặc 429, không retry khi 4xx, tối đa 3 lần, backoff 20/80/180 ms. Đây chính là chính sách mặc định của phần lớn SDK và service mesh. Rồi gửi 1.000 request, trong đó 300 request cố tình sai dữ liệu (tuoi: 5):

Hình 2: Kết quả thật. Mục 1 là thân phản hồi client nhìn thấy, mục 2 là chi phí đo được, mục 3 là lúc server hỏng thật.
| bản A (500 hết) | bản B (200 hết) | bản C (RFC 9457) | |
|---|---|---|---|
| Request tới server | 1.900 | 1.000 | 1.000 |
| Tỉ lệ 5xx trên server | 63,2% | 0% | 0% |
| p50 một request lỗi | 290 ms | 0,03 ms | 0,03 ms |
Bản A nhận 1.900 request cho 1.000 ý định. 300 request sai dữ liệu, mỗi request bị client retry 3 lần vì server trả 5xx, thành 900 request thừa. Chúng không bao giờ thành công — dữ liệu sai thì gửi lại vẫn sai — nhưng client không có cách nào biết điều đó, vì hợp đồng nói "5xx nghĩa là server có vấn đề, hãy thử lại". Và mỗi request lỗi mất 290 ms thay vì 0,03 ms, toàn bộ là thời gian ngồi chờ backoff.
Tỉ lệ 5xx 63,2% trong khi server hoàn toàn khoẻ. Tính trên 1.900 request server xử lý, 1.200 là 500 (300 lỗi gốc + 900 retry). Nếu đội vận hành có cảnh báo "5xx vượt 5% thì gọi người trực" — một ngưỡng rất bình thường — thì bản A gọi người trực vì client gửi sai tuổi. Sau vài lần như vậy, cảnh báo đó bị tắt đi, và lần database chết thật sẽ không ai được gọi.
Bản B có con số đẹp nhất bảng, và đó là vấn đề. 1.000 request, 0% 5xx, 0,03 ms — vì 300 lỗi đều mang mã 200. Client retry không nhìn thấy chúng; biểu đồ không nhìn thấy chúng; cache HTTP và CDN coi chúng là phản hồi thành công và có thể lưu lại. Mục 3 của Hình 2 cho thấy đầu kia của cùng một vấn đề: khi database chết, bản B cũng trả 200 — server chết mà biểu đồ vẫn xanh.
Bản C giống bản B ở mọi con số, nhưng vì lý do đúng. 1.000 request vì client nhận 422 và biết không nên gửi lại; 0% 5xx vì không có lỗi nào là của server. Khi database chết thật (mục 3), bản C trả 500 — và lúc đó cảnh báo 5xx gọi đúng người.
Chuyện lộ ra ngoài
Mục 1 và mục 3 của Hình 2 là phần ít được đo nhưng dễ thấy nhất. Bản A, với email trùng, trả cho client nguyên văn:
ERROR: duplicate key value violates unique constraint "customers_email_key" (SQLSTATE 23505)
Người gọi giờ biết có bảng customers, có ràng buộc tên customers_email_key, và server dùng PostgreSQL. Khi database không với tới, bản A còn trả cả địa chỉ nội bộ 172.23.0.2:5999. Mỗi mẩu đó riêng lẻ thì vô hại; gom lại qua vài chục thông báo lỗi khác nhau là một bản đồ hệ thống miễn phí cho bất kỳ ai muốn vẽ.
Bản C trả {"type":"/loi/noi-bo","title":"Lỗi nội bộ","instance":"/loi/tra-cuu/req-95462"} và ghi nguyên văn lỗi vào log phía server với cùng mã req-95462. Client có thứ để báo cho bộ phận hỗ trợ, bộ phận hỗ trợ có thứ để tìm trong log, và không có gì về cấu trúc nội bộ đi ra ngoài. Thứ duy nhất mất đi là sự tiện lợi khi gỡ lỗi bằng curl lúc phát triển — và nó mua lại được bằng cách bật chi tiết trong môi trường dev.
Vì sao 500 là thất bại của người thiết kế
Mã 5xx có một nghĩa rất hẹp trong RFC 9110: server biết nó đã sai hoặc không làm được việc nó phải làm. Mọi hệ thống phía client — SDK, service mesh, load balancer, hệ thống cảnh báo — đều được xây trên nghĩa đó: 5xx thì thử lại, thì chuyển sang instance khác, thì gọi người trực. Trả 500 cho một request có tuoi: 5 là nói dối với tất cả những hệ thống đó cùng lúc, và phép đo ở trên là cái giá của lời nói dối: 900 request thừa, 290 ms mỗi request, 63% báo động giả.
Nhìn theo cách khác: một lỗi 500 thật sự nghĩa là có một đường đi trong code mà người viết chưa nghĩ tới. Đó là thông tin quý — nó chỉ đúng chỗ còn thiếu một case trong switch của bản C. Khi 500 bị dùng cho mọi thứ, thông tin đó biến mất trong nhiễu.
Đánh đổi cần cân nhắc
Phân loại lỗi là công việc thật, và nó nằm ở ranh giới. Bản C dài gấp mười bản A vì nó phải biết errThieuEmail là lỗi client, 23505 là trùng khoá, còn lại là lỗi server. Việc này nên làm một lần ở một chỗ — một middleware hoặc một hàm traLoi(err) ánh xạ loại lỗi sang mã — chứ không rải trong từng handler. Mỗi loại lỗi nghiệp vụ mới (errHetHang, errKhongDuQuyen) phải được thêm vào bảng ánh xạ; quên thì nó rơi xuống default và thành 500 — tức là hệ thống báo cho bạn biết có loại lỗi chưa được xếp loại, đó là hành vi đúng.
type là hợp đồng, detail thì không. Client nên rẽ nhánh theo type (/loi/trung-email) và mã HTTP, chưa bao giờ theo chuỗi trong title hay detail — những chuỗi đó dành cho người đọc và sẽ đổi khi dịch sang ngôn ngữ khác hoặc sửa câu chữ. Đổi type là đổi hợp đồng, phải đi qua phiên bản API (chủ đề phần sau).
422 hay 400 cho dữ liệu sai — chọn một và nhất quán. RFC 9110 định nghĩa 400 là "request có vấn đề về cú pháp hoặc nội dung", 422 (từ WebDAV, nay nằm trong RFC 9110) là "cú pháp đúng nhưng không xử lý được về nghĩa". Nhiều API dùng 400 cho cả hai, và điều đó không sai. Điều sai là trộn lẫn tuỳ hứng trong cùng một API.
Retry-After cho 503 và 429. Khi server quá tải, trả 503 kèm Retry-After để client biết chờ bao lâu, thay vì để nó tự đoán bằng backoff. Bản C có sẵn dòng đó; phần 8 về giới hạn tần suất sẽ đo xem header này thay đổi hành vi client bao nhiêu.
Phép đo ở đây dùng backoff 20/80/180 ms để chạy nhanh. Chính sách thật thường là 1/2/4 giây, nghĩa là mỗi request lỗi của bản A tốn 7 giây thay vì 290 ms, và người dùng ngồi nhìn vòng xoay suốt thời gian đó trước khi được báo "tuổi không hợp lệ".
Ba ý mang về
- Trả 500 cho lỗi của client là tốn kém đo được: 300 request sai thành 900 request thừa, mỗi request lỗi chờ 290 ms vô ích, và tỉ lệ 5xx 63% trên một server hoàn toàn khoẻ — đủ để tắt cảnh báo thật.
- Mã HTTP trả lời câu hỏi "ai gây ra lỗi và có nên thử lại không": 4xx là client, gửi lại vô ích; 5xx là server, hãy thử lại. Mọi SDK, mesh và hệ thống cảnh báo đều tin vào quy ước đó. "Mọi thứ đều 200" phá vỡ nó theo chiều ngược lại: server chết mà biểu đồ vẫn xanh.
- Thân lỗi phải máy đọc được và không lộ nội bộ:
typeổn định để client rẽ nhánh,errorstheo trường để hiện đúng chỗ sai, và với lỗi server thì một mã tra cứu đối chiếu được với log — không bao giờ làerr.Error(), thứ mang theo tên bảng, tên ràng buộc và địa chỉ IP.
Nguồn
- RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457
- RFC 9110 — HTTP Semantics, mục 15 Status Codes: https://www.rfc-editor.org/rfc/rfc9110#section-15
- Zalando — RESTful API Guidelines, mục Common data types: Problem: https://opensource.zalando.com/restful-api-guidelines/#176
- PostgreSQL docs — Error Codes (SQLSTATE 23505): https://www.postgresql.org/docs/16/errcodes-appendix.html
Phần sau bàn về chuyện xảy ra khi hợp đồng — kể cả hợp đồng lỗi này — buộc phải đổi: phiên bản trong URL, trong header, hay không đánh phiên bản gì cả, và cái giá đo được của mỗi cách khi còn client cũ đang gọi.