Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn là BA của một ví điện tử ở Việt Nam. Đội mobile gọi API thanh toán của bạn, giao dịch thất bại, và tất cả những gì họ nhận về là dòng chữ "error": "something went wrong" kèm mã 500. Người dùng cuối thấy màn hình trắng. Tổng đài bị gọi dồn dập. Đội dev mobile không biết nên hiện thông báo gì, nên retry hay dừng lại. Còn bạn — người viết spec — thì ngồi giữa hai bên, không có dữ liệu để phân xử ai đúng ai sai.
Đó chính xác là cái giá của một thiết kế error response tệ. Trong các bài trước bạn đã học về status codes (Bài 9) — biết được 4xx là lỗi phía client, 5xx là lỗi phía server. Nhưng status code chỉ là phần "tiêu đề" của câu chuyện. Phần "nội dung" — tức cấu trúc dữ liệu mô tả lỗi nằm trong response body — mới là thứ quyết định một API dễ tích hợp hay là cơn ác mộng.
Là một BA kỹ thuật, bạn không cần tự code phần xử lý lỗi, nhưng bạn là người định nghĩa hợp đồng (contract) giữa các hệ thống. Nếu bạn chuẩn hóa được cấu trúc error response ngay từ đầu, bạn tiết kiệm cho đội consumer hàng trăm giờ xử lý từng trường hợp lẻ tẻ, giảm số lượng bug "khó tái hiện", và biến phần xử lý lỗi từ một mớ hỗn loạn thành thứ có thể dự đoán được. Bài này tập trung riêng vào việc thiết kế cấu trúc cho phần thân lỗi và các thông lệ tốt xoay quanh nó.
Khái niệm cốt lõi
Chuẩn hóa ngay từ đầu
Nguyên tắc số một: mọi lỗi từ API của bạn nên có chung một hình dạng (shape). Khi consumer biết chắc rằng bất kỳ lỗi nào cũng trả về cùng một cấu trúc JSON, họ chỉ cần viết một lần đoạn code đọc lỗi và dùng lại ở mọi nơi. Ngược lại, nếu lỗi đăng nhập có hình dạng khác lỗi thanh toán, khác lỗi validate form, thì phía consumer phải viết hàng chục nhánh xử lý riêng — và mỗi nhánh là một chỗ có thể sót.
Hãy nghĩ về error response như một hợp đồng phụ trong hợp đồng API. Khi mọi thứ chạy đúng, consumer đọc phần "success body". Khi có trục trặc, họ đọc phần "error body". Phần error body này phải ổn định và nhất quán y như phần success.
Một cấu trúc error response đề xuất
Đây là khung sườn mà bạn có thể đưa vào hầu hết spec:
{
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Số dư không đủ để thực hiện giao dịch.",
"details": [
{
"field": "amount",
"issue": "Số tiền 500000 vượt quá số dư khả dụng 320000."
}
],
"trace_id": "req_8f3a91cd2e",
"doc_url": "https://docs.vidu.vn/errors/INSUFFICIENT_BALANCE"
}
}
Hãy mổ xẻ từng trường, vì mỗi trường giải quyết một nhu cầu khác nhau của một loại độc giả khác nhau:
code— mã lỗi dạng chuỗi, ổn định, dành cho máy đọc. Đây là thứ code của consumer dùng để rẽ nhánh (if code == "INSUFFICIENT_BALANCE"). Quan trọng: code phải là một định danh không đổi, không phụ thuộc ngôn ngữ. Đừng bắt consumer parse câu tiếng Việt để đoán loại lỗi.
message— câu mô tả dành cho con người đọc, thường là dev đang debug. Nó nên rõ ràng nhưng không tiết lộ chi tiết nhạy cảm của hệ thống. Lưu ý: message này thường không phải thứ hiện thẳng cho người dùng cuối — đội frontend nên ánh xạ từcodesang thông báo thân thiện riêng.
details— mảng các lỗi con, đặc biệt hữu ích cho lỗi validate khi một request có nhiều trường sai cùng lúc. Mỗi phần tử chỉ rõ trường nào (field) và vấn đề gì (issue).
trace_id— định danh duy nhất của request này trong hệ thống của bạn. Khi khách hàng báo lỗi, họ gửitrace_id, và đội vận hành dùng nó để tra log đúng request đó (liên quan đến distributed tracing ở Bài 52). Đây là cây cầu nối giữa "lỗi người dùng nhìn thấy" và "log server".
doc_url— đường dẫn tới tài liệu giải thích sâu về mã lỗi này. Stripe làm rất tốt điều này.
code và message. details cần khi có validate nhiều trường. trace_id gần như luôn nên có ở hệ thống production nghiêm túc.Phân tầng: lỗi cho máy vs lỗi cho người
Một sai lầm tư duy phổ biến là gộp chung "thông báo cho dev" và "thông báo cho người dùng cuối" làm một. Hãy tách bạch:
- Tầng máy/dev (
code,details,trace_id): ổn định, kỹ thuật, dùng để xử lý logic và debug. - Tầng người dùng cuối: thông báo thân thiện, đa ngôn ngữ, do frontend quyết định dựa trên
code. API không nên cố ép thông báo "Quý khách vui lòng kiểm tra lại số dư" vào response, vì cùng một code có thể cần hiển thị khác nhau trên web, app, hay email.
Quan hệ giữa status code và error body
Status code và error body bổ trợ nhau, không thay thế nhau. Quy ước thực dụng:
- Status code cho consumer biết nhóm vấn đề và nên hành xử ra sao ở mức giao thức (400 = đừng retry, sửa request; 401 = đăng nhập lại; 429 = chờ rồi retry; 503 = thử lại sau).
- Error body cho biết chính xác chuyện gì xảy ra để xử lý chi tiết.
200 OK nhưng "success": false). Điều này phá vỡ mọi công cụ và middleware vốn dựa vào status code, khiến cache, monitoring và retry logic hiểu sai hoàn toàn.Tình huống thực tế
Tình huống 1: Ví điện tử "MoMoPay giả định" và cơn ác mộng 500
Một fintech ở TP.HCM (gọi là VíNhanh cho dễ) ra mắt API thanh toán. Ban đầu đội backend xử lý lỗi kiểu "tiện đâu trả đó": lỗi số dư trả 500 với body {"msg": "fail"}, lỗi sai OTP trả 400 với body {"error": "invalid"}, lỗi quá hạn token trả HTML của trang lỗi mặc định framework.
Hậu quả sau một tháng go-live: đội mobile của đối tác (một sàn TMĐT) phải viết hơn 20 nhánh if để đoán lỗi dựa trên việc so chuỗi text. Khi backend đổi "fail" thành "failed" trong một bản vá nhỏ, toàn bộ logic phía mobile vỡ trận vì so chuỗi không khớp. Tỷ lệ giao dịch "treo" (người dùng không biết thành công hay thất bại) lên tới 4%.
BA của VíNhanh ngồi lại, định nghĩa một enum mã lỗi cố định: INSUFFICIENT_BALANCE, INVALID_OTP, OTP_EXPIRED, TOKEN_EXPIRED, DAILY_LIMIT_EXCEEDED... Tất cả đóng gói trong cấu trúc {"error": {"code", "message", "trace_id"}} chuẩn, kèm bảng ánh xạ status code rõ ràng (INVALID_OTP → 400, TOKEN_EXPIRED → 401, DAILY_LIMIT_EXCEEDED → 429).
Bài học rút ra: Consumer phải rẽ nhánh dựa trên code ổn định, không bao giờ dựa trên message. Một mã lỗi dạng enum là hợp đồng; câu chữ trong message thì có thể đổi bất cứ lúc nào. Sau khi chuẩn hóa, tỷ lệ giao dịch treo của VíNhanh giảm còn dưới 0.5%.
Tình huống 2: Form đăng ký nhiều trường sai cùng lúc
Một nền tảng tuyển dụng (giả định tên ViecTot) có API đăng ký ứng viên với 8 trường. Phiên bản đầu, mỗi lần submit form chỉ trả về một lỗi đầu tiên gặp phải: nhập sai email, server trả "email không hợp lệ"; người dùng sửa email, submit lại, lại nhận "số điện thoại sai"; sửa tiếp, lại "mật khẩu quá ngắn". Người dùng phải submit 4 lần mới qua được form, tỷ lệ bỏ cuộc (drop-off) ở bước đăng ký lên 38%.
BA đề xuất dùng trường details dạng mảng để trả về toàn bộ lỗi validate trong một lần:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Một số trường không hợp lệ.",
"details": [
{"field": "email", "issue": "Email không đúng định dạng."},
{"field": "phone", "issue": "Số điện thoại phải có 10 chữ số."},
{"field": "password", "issue": "Mật khẩu tối thiểu 8 ký tự."}
]
}
}
Frontend nhận một lần, tô đỏ cả ba ô cùng lúc. Người dùng sửa hết một lượt rồi submit lại. Drop-off giảm xuống 12%.
Bài học rút ra: Với lỗi validate, hãy thiết kế để trả tất cả lỗi trong một response thay vì từng lỗi một. Trường details dạng mảng với field + issue là chuẩn mực cho việc này. Đây là điểm BA hay bỏ sót khi viết spec vì chỉ nghĩ đến "happy path".
Tình huống 3: Lộ thông tin nhạy cảm qua message lỗi
Một startup B2B SaaS ở Singapore phục vụ thị trường Đông Nam Á gặp sự cố bảo mật suýt nghiêm trọng. API đăng nhập của họ trả message rất "chi tiết": nếu email không tồn tại, trả "Email này chưa đăng ký"; nếu email tồn tại nhưng sai mật khẩu, trả "Mật khẩu không đúng". Nghe có vẻ thân thiện, nhưng kẻ tấn công đã lợi dụng sự khác biệt này để dò ra danh sách email nào thực sự có tài khoản trong hệ thống (user enumeration) — bước đầu của một chiến dịch tấn công.
Đội security yêu cầu BA viết lại spec: cả hai trường hợp đều trả về cùng một lỗi mơ hồ INVALID_CREDENTIALS với message "Email hoặc mật khẩu không đúng", cùng status 401. Đồng thời, các lỗi 500 nội bộ bị cấm tuyệt đối việc trả stack trace hay tên bảng database ra ngoài — chỉ trả INTERNAL_ERROR kèm trace_id, còn chi tiết kỹ thuật chỉ ghi vào log nội bộ.
Bài học rút ra: Error message là một bề mặt tấn công (attack surface). Càng "giúp đỡ" sai chỗ, bạn càng giúp kẻ xấu. Nguyên tắc: chi tiết kỹ thuật đi vào log (truy được qua trace_id), thông tin trả ra ngoài phải vừa đủ để consumer xử lý mà không rò rỉ cấu trúc nội bộ hay sự tồn tại của tài khoản. (Chủ đề này liên hệ với OWASP API Top 10 ở Bài 50.)
Hướng dẫn từng bước
Khi bạn — với vai trò BA — cần định nghĩa error response cho một API trong spec, hãy đi theo trình tự sau:
- Liệt kê tất cả các kịch bản lỗi của từng endpoint. Ngồi cùng dev và đặt câu hỏi "Endpoint này có thể hỏng vì những lý do gì?". Ví dụ với endpoint thanh toán: số dư không đủ, token hết hạn, vượt hạn mức ngày, tài khoản bị khóa, dịch vụ bảo trì. Đây là phần quan trọng nhất và cũng hay bị bỏ sót nhất.
- Đặt tên mã lỗi (
code) theo quy ước rõ ràng. Dùng dạngUPPER_SNAKE_CASE, mô tả nguyên nhân chứ không phải triệu chứng.INSUFFICIENT_BALANCEtốt hơnERROR_402. Giữ danh sách này trong một bảng tập trung để cả tổ chức dùng chung, tránh mỗi đội đặt một kiểu.
- Ánh xạ mỗi
codesang đúng một HTTP status code. Lập bảng hai cột: code ↔ status. Quy ước nhanh: lỗi do input của client → 4xx; lỗi do hệ thống → 5xx. Bảng này phải nằm trong spec để dev không tự ý chọn status.
- Thiết kế body chuẩn và quyết định trường nào bắt buộc. Tối thiểu
code+message. Thêmdetailscho endpoint có validate. Thêmtrace_idcho mọi endpoint production.
- Phân biệt rõ message cho dev và thông báo cho người dùng cuối. Ghi chú trong spec rằng frontend sẽ ánh xạ
codesang text hiển thị, để đội backend không bị áp lực viết câu chữ thân thiện trong API.
- Rà soát góc độ bảo mật. Với từng message, tự hỏi "Câu này có rò rỉ thông tin nội bộ không?". Đặc biệt chú ý nhóm lỗi xác thực và lỗi 500.
- Viết tài liệu và ví dụ mẫu cho từng mã lỗi. Mỗi
codenên có một mục mô tả: khi nào xảy ra, consumer nên làm gì (retry hay không), ví dụ body. Đây chính là phầndoc_urltrỏ tới.
Lỗi thường gặp & mẹo
- Trả 200 OK kèm lỗi bên trong body. Đây là lỗi kinh điển phá vỡ mọi tooling. Status code phải phản ánh đúng kết quả thực sự.
- Bắt consumer parse
messageđể đoán loại lỗi. Message là để con người đọc, sẽ thay đổi. Luôn cung cấpcodeổn định cho máy.
- Quên lỗi validate nhiều trường. Nếu chỉ trả lỗi đầu tiên, trải nghiệm người dùng rất tệ. Dùng
detailsdạng mảng.
- Rò rỉ stack trace, câu SQL, tên bảng ra ngoài. Vừa lộ kiến trúc, vừa là lỗ hổng bảo mật. Chi tiết kỹ thuật phải nằm trong log, không nằm trong response.
- Mỗi đội/microservice tự bịa cấu trúc lỗi riêng. Khi hệ thống có nhiều service (Bài 38), việc thiếu chuẩn chung khiến API Gateway và consumer khốn khổ. Hãy có một "error contract" toàn tổ chức.
- Quên
trace_id. Không có nó, mỗi lần khách báo lỗi là một cuộc truy tìm mò mẫm trong log. Mẹo: yêu cầu trảtrace_idngay cả trong response thành công để dễ đối soát.
- Mẹo cho BA: Hãy mượn các "error catalog" công khai của Stripe, GitHub, hay Twilio làm tài liệu tham khảo khi viết spec. Họ đã trải qua hàng triệu lần va vấp để đúc kết ra cấu trúc tốt — bạn không cần phát minh lại.
- Mẹo: Đưa một bảng "ma trận lỗi" (error matrix) vào mọi spec API: cột gồm code, status, khi nào xảy ra, consumer nên làm gì, có nên retry không. Bảng này biến phần lỗi từ thứ mơ hồ thành thứ kiểm thử được.
Bài tập thực hành
- Thiết kế error contract. Cho một endpoint
POST /orders(tạo đơn hàng) của một sàn TMĐT Việt Nam, hãy liệt kê ít nhất 6 kịch bản lỗi có thể xảy ra. Với mỗi kịch bản, đặt một mãcode, gán một HTTP status, và viết một dòngmessage.
- Sửa lỗi response xấu. Cho response sau, hãy chỉ ra ít nhất 3 vấn đề và viết lại cho đúng chuẩn:
HTTP 200 OK
{"success": false, "error": "User not found in table tbl_users at line 482"}
- Validate nhiều trường. Một form đăng ký gồm
full_name,email,phone,password. Giả sử người dùng để trốngfull_name, nhập sai định dạngemail, vàpasswordchỉ 4 ký tự. Hãy viết error response hoàn chỉnh dùng cấu trúcdetailsdạng mảng.
- Soi bảo mật. Cho API quên mật khẩu. Hãy thiết kế response cho hai trường hợp "email tồn tại" và "email không tồn tại" sao cho không cho phép kẻ tấn công dò được email nào có trong hệ thống.
- Viết error catalog. Chọn ba mã lỗi từ bài tập 1, viết cho mỗi mã một mục tài liệu nhỏ: khi nào xảy ra, consumer nên xử lý thế nào, có nên retry không.
Tóm tắt
Error response design là một trong những phần "ít hào nhoáng nhưng cực kỳ giá trị" mà một BA kỹ thuật có thể đóng góp. Những điểm cốt lõi cần nhớ:
- Chuẩn hóa ngay từ đầu: mọi lỗi có chung một hình dạng để consumer code một lần, dùng mọi nơi.
- Cấu trúc đề xuất:
code(cho máy, ổn định),message(cho dev đọc),details(mảng lỗi validate),trace_id(cầu nối tới log),doc_url(tài liệu). - Tách tầng máy và tầng người dùng: API trả
code; frontend ánh xạ sang thông báo thân thiện. - Status code và body bổ trợ nhau: đừng bao giờ trả 200 kèm lỗi bên trong.
- Trả tất cả lỗi validate cùng lúc qua
details. - Bảo mật là ưu tiên: không rò rỉ stack trace, tên bảng, hay sự tồn tại của tài khoản; chi tiết đi vào log, không ra response.