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

Ảnh chụp đoạn mã nền tối hợp đồng lỗi mã đúng loại thân máy đọc được không lộ nội bộ với Go net/http và pgx cho POST /customers theo RFC 9457 application/problem+json, bản A và B là hai cách sai hay gặp gồm bản A cứ lỗi là 500 nhét err.Error vào thân bằng http.Error w err.Error 500, và bản B mọi thứ đều 200 nhìn trường success bằng json Encode map success false message err.Error, bản C phân loại lỗi rồi mới chọn mã gồm switch với case err bằng nil thì WriteHeader 201, case errors.Is err errThieuEmail hoặc errTuoi là lỗi của client thì traProblem với Type /loi/du-lieu-khong-hop-le Title Dữ liệu không hợp lệ Status 422 và Errors chứa Truong tuoi LyDo err.Error, default thì khai pgErr kiểu pgconn.PgError và nếu errors.As và pgErr.Code bằng 23505 unique_violation thì traProblem với Type /loi/trung-email Title Email đã được dùng Status 409 rồi return, còn lỗi thật của server thì log đầy đủ trả client một mã tra cứu không trả err.Error bằng ma là req cộng số ngẫu nhiên, log.Printf ma err, traProblem với Type /loi/noi-bo Title Lỗi nội bộ Status 500 Instance /loi/tra-cuu/ cộng ma, hàm traProblem đặt Content-Type application/problem+json, nếu Status 503 thì đặt Retry-After 2, WriteHeader p.Status và Encode p

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ả 500 và nguyên văn err.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ộng errors liệ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èm err.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):

Ảnh chụp output thật nền tối 300 lỗi của client thành 900 request thừa 63 phần trăm 5xx với Go 1.23 pgx v5 PostgreSQL 16 cùng lõi nghiệp vụ ba cách trả lỗi client retry 5xx 3 lần, mục 1 cùng bốn tình huống client nhận được gì cho thấy bản A JSON hỏng trả 500 invalid character looking for beginning of value, tuổi 7 trả 500 tuổi phải từ 18 đến 120, email trùng trả 500 ERROR duplicate key value violates unique constraint customers_email_key, bản B JSON hỏng trả 200 message invalid character success false và email trùng trả 200 message ERROR duplicate key value violates success false, bản C JSON hỏng trả 400 type /loi/json-hong title Thân request không phải JSON hợp lệ, thiếu email trả 422 type /loi/du-lieu-khong-hop-le errors truong email, tuổi 7 trả 422 errors truong tuoi, email trùng trả 409 type /loi/trung-email title Email đã được dùng, mục 2 1.000 request 300 sai dữ liệu lỗi của client với client retry 5xx backoff 20 80 180 ms cho thấy bản A 1900 request tới server tỉ lệ 5xx trên server 63.2 phần trăm 900 request thừa p50 request lỗi 290.06 ms p99 294.56 ms, bản B 1000 request 0 phần trăm 0 thừa 0.03 ms và 0.13 ms với ghi chú mã 200, bản C 1000 request 0 phần trăm 0 thừa 0.03 ms và 0.05 ms, mục 3 lỗi thật của server database không với tới cho thấy bản A 500 failed to connect to user lab database lab 172.23.0.2 cổng 5999 pg-lab dial error còn bản C 500 type /loi/noi-bo title Lỗi nội bộ instance /loi/tra-cuu/req-95462 và log server req-95462 failed to connect 172.23.0.2 cổng 5999 connection refused, nói thẳng bản A lộ tên bảng tên ràng buộc địa chỉ database và làm client retry thứ không thể sửa bằng retry làm biểu đồ 5xx báo động 63 phần trăm trong khi server khoẻ còn bản B ngược lại server chết biểu đồ vẫn xanh

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ề

  1. 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.
  2. 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.
  3. Thân lỗi phải máy đọc được và không lộ nội bộ: type ổn định để client rẽ nhánh, errors theo 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

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.