Mẫu xử lý lỗi API Go chuẩn hóa lỗi có kiểu, ánh xạ mã trạng thái HTTP, request ID và thông điệp an toàn mà không làm lộ nội bộ.

Khi mỗi endpoint báo lỗi theo một cách khác nhau, client dần dần mất lòng tin vào API của bạn. Một route trả { "error": "not found" }, route khác trả { "message": "missing" }, và route thứ ba gửi plain text. Dù ý nghĩa có gần giống, mã client giờ phải đoán xem chuyện gì xảy ra.
Hệ quả đến rất nhanh. Các team viết logic phân tích mong manh và thêm trường hợp đặc biệt cho từng endpoint. Retry trở nên rủi ro vì client không thể biết “thử lại sau” khác với “dữ liệu bạn gửi sai”. Ticket hỗ trợ tăng lên vì client chỉ thấy thông điệp mơ hồ, và đội backend của bạn khó mà khớp nó với một dòng log trên server.
Một kịch bản phổ biến: ứng dụng mobile gọi ba endpoint trong quá trình đăng ký. Endpoint đầu trả HTTP 400 với map lỗi theo trường, endpoint hai trả HTTP 500 kèm chuỗi stack trace, và endpoint ba trả HTTP 200 với { "ok": false }. Team app triển khai ba bộ xử lý lỗi khác nhau, và team backend vẫn nhận báo cáo kiểu “đăng ký thỉnh thoảng thất bại” mà không có manh mối rõ ràng để bắt đầu.
Mục tiêu là một hợp đồng dự đoán được. Client nên có thể đọc được chuyện gì đã xảy ra, liệu có phải lỗi do họ hay server, có nên retry hay không, và một request ID để dán vào ticket hỗ trợ.
Phạm vi: bài viết tập trung vào API HTTP trả JSON (không phải gRPC), nhưng cùng ý tưởng áp dụng ở bất kỳ nơi nào bạn trả lỗi cho hệ thống khác.
Chọn một hợp đồng rõ ràng cho lỗi và bắt mọi endpoint tuân thủ. “Nhất quán” có nghĩa là cùng cấu trúc JSON, cùng ý nghĩa các trường, và cùng hành vi dù handler nào gặp lỗi. Khi làm được vậy, client ngừng đoán và bắt đầu xử lý lỗi đúng.
Một hợp đồng hữu dụng giúp client quyết định bước tiếp theo. Với hầu hết ứng dụng, mọi phản hồi lỗi nên trả lời ba câu hỏi:
Một bộ quy tắc thực tế:
Quyết định trước những gì không bao giờ được hiển thị trong phản hồi. Các mục “không bao giờ” phổ biến gồm đoạn SQL, stack trace, hostname nội bộ, secret, và chuỗi lỗi thô từ dependency.
Giữ sự phân tách rõ ràng: một thông điệp ngắn cho người dùng (an toàn, lịch sự, có thể hành động) và chi tiết nội bộ (lỗi đầy đủ, stack, ngữ cảnh) chỉ lưu trong log. Ví dụ: “Không thể lưu thay đổi của bạn. Vui lòng thử lại.” là an toàn. “pq: duplicate key value violates unique constraint users_email_key” thì không.
Khi mọi endpoint tuân theo cùng hợp đồng, client có thể xây dựng một handler lỗi duy nhất và tái sử dụng ở mọi nơi.
Client chỉ xử lý lỗi sạch nếu mọi endpoint trả về cùng một hình dạng. Chọn một phong bì JSON và giữ nó ổn định.
Mặc định thực tế là một đối tượng error cộng với request_id ở cấp cao:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
HTTP status cho biết hạng mục chung (400, 401, 409, 500). error.code dạng máy cho biết trường hợp cụ thể để client có thể phân nhánh. Sự tách biệt này quan trọng vì nhiều vấn đề khác nhau có thể cùng chung một status. Một app mobile có thể hiển thị UI khác cho EMAIL_TAKEN so với WEAK_PASSWORD, ngay cả khi cả hai đều là 400.
Giữ error.message an toàn và dễ đọc cho con người. Nó nên giúp người dùng sửa lỗi, nhưng không bao giờ tiết lộ nội bộ (SQL, stack trace, tên provider, đường dẫn tập tin).
Các trường tùy chọn hữu ích khi chúng giữ tính dự đoán:
details.fields là map từ trường tới thông điệp.details.retry_after_seconds.details.docs_hint dưới dạng plain text (không phải URL).Vì backward compatibility, coi giá trị error.code như một phần hợp đồng API. Thêm mã mới mà không thay đổi nghĩa cũ. Chỉ thêm các trường tùy chọn, và giả sử client sẽ bỏ qua trường không nhận ra.
Xử lý lỗi trở nên lộn xộn khi mỗi handler tự nghĩ ra cách báo thất bại. Một tập nhỏ các lỗi có kiểu khắc phục được điều đó: handler trả các kiểu lỗi đã biết, và một lớp phản hồi chung biến chúng thành phản hồi nhất quán.
Một bộ khởi đầu thực tế bao phủ hầu hết endpoint:
Chìa khóa là ổn định ở mức trên cùng, dù nguyên nhân gốc thay đổi. Bạn có thể bọc lỗi cấp thấp hơn (SQL, mạng, parse JSON) trong khi vẫn trả cùng loại công khai mà middleware có thể phát hiện.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
Trong handler, trả NotFoundError{Resource: "user", ID: id, Err: err} thay vì để lộ sql.ErrNoRows trực tiếp.
Để kiểm tra lỗi, ưu tiên errors.As cho các kiểu tùy chỉnh và errors.Is cho các sentinel error. Sentinel errors (như var ErrUnauthorized = errors.New("unauthorized")) phù hợp cho trường hợp đơn giản, nhưng kiểu tùy chỉnh thắng thế khi bạn cần ngữ cảnh an toàn (ví dụ resource nào bị thiếu) mà không thay đổi hợp đồng phản hồi công khai.
Cần nghiêm ngặt về những gì đính kèm:
Err gốc, thông tin stack, lỗi SQL thô, token, dữ liệu người dùng.Sự phân tách đó cho phép bạn giúp client mà không lộ nội bộ.
Khi đã có lỗi có kiểu, công việc tiếp theo là buồn chán nhưng cần thiết: cùng một kiểu lỗi luôn phải tạo ra cùng một HTTP status. Client sẽ dựa vào điều đó để xây dựng logic.
Một ánh xạ thực tế cho hầu hết API:
| Error type (ví dụ) | Status | Khi nào dùng |
|---|---|---|
| BadRequest (JSON sai, thiếu query param) | 400 | Request không hợp lệ ở mức giao thức hoặc định dạng. |
| Unauthenticated (không có/ token không hợp lệ) | 401 | Client cần xác thực. |
| Forbidden (không có quyền) | 403 | Auth hợp lệ nhưng không được phép truy cập. |
| NotFound (ID resource không tồn tại) | 404 | Resource yêu cầu không có (hoặc bạn chọn che tồn tại). |
| Conflict (unique constraint, version mismatch) | 409 | Request đúng định dạng nhưng xung đột với trạng thái hiện tại. |
| ValidationFailed (quy tắc trường) | 422 | Hình dạng đúng nhưng validation nghiệp vụ thất bại (email format, min length). |
| RateLimited | 429 | Quá nhiều request trong cửa sổ thời gian. |
| Internal (lỗi không mong đợi) | 500 | Bug hoặc lỗi không mong đợi. |
| Unavailable (dependency down, timeout, bảo trì) | 503 | Vấn đề phía server tạm thời. |
Hai phân biệt giúp tránh nhiều nhầm lẫn:
Hướng dẫn retry quan trọng:
Request ID là một giá trị ngắn, duy nhất nhận diện một cuộc gọi API end-to-end. Nếu client thấy nó trong mọi phản hồi, support trở nên đơn giản: “Gửi request ID cho tôi” thường là đủ để tìm đúng log và nguyên nhân.
Thói quen này có lợi cho cả phản hồi thành công lẫn lỗi.
Dùng một quy tắc rõ ràng: nếu client gửi request ID, giữ nguyên. Nếu không, tạo mới.
X-Request-Id).Đặt request ID ở ba nơi:
request_id trong schema chuẩn)Với endpoint batch hoặc job nền, giữ một parent request ID. Ví dụ: client upload 200 dòng, 12 dòng fail validation, và bạn enqueue công việc. Trả một request_id cho toàn bộ gọi, và bao gồm parent_request_id trên mỗi job và mỗi lỗi theo mục. Bằng vậy bạn có thể truy vết “một upload” ngay cả khi nó tỏa ra nhiều tác vụ.
Client cần phản hồi lỗi rõ ràng, ổn định. Log của bạn cần sự thật lộn xộn. Tách hai thế giới này: trả thông điệp an toàn và mã lỗi công khai cho client, trong khi ghi nguyên nhân nội bộ, stack, và ngữ cảnh trong log server.
Ghi một event có cấu trúc cho mỗi phản hồi lỗi, có thể tìm bằng request_id.
Các trường nên duy trì nhất quán:
Lưu chi tiết nội bộ chỉ trong server logs (hoặc kho lỗi nội bộ). Client không bao giờ nên thấy lỗi DB thô, câu truy vấn, stack trace, hay thông điệp nhà cung cấp. Nếu bạn chạy nhiều service, một trường nội bộ như source (api, db, auth, upstream) có thể giúp phân loại nhanh.
Theo dõi các endpoint ồn ào và lỗi bị rate-limit. Nếu một endpoint có thể sinh ra cùng 429 hoặc 400 hàng nghìn lần/phút, tránh spam log: sample các event lặp lại, hoặc hạ mức severity cho lỗi dự kiến trong khi vẫn đếm chúng trong metrics.
Metrics bắt bệnh sớm hơn logs. Theo dõi số lượng theo nhóm HTTP status và error code, và cảnh báo khi có spike đột ngột. Nếu RATE_LIMITED tăng 10x sau deploy, bạn sẽ thấy nhanh ngay cả khi logs bị sample.
Cách dễ nhất để làm cho lỗi nhất quán là ngừng xử lý chúng “mọi nơi” và dẫn chúng qua một pipeline nhỏ. Pipeline này quyết định client thấy gì và bạn lưu gì cho logs.
Bắt đầu với một tập nhỏ mã lỗi mà client có thể phụ thuộc (ví dụ: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Bọc chúng trong một lỗi có kiểu mà chỉ phơi bày các trường công khai an toàn (code, thông điệp an toàn, details tùy chọn như trường nào sai). Giữ nguyên nhân nội bộ riêng.
Rồi triển khai một hàm dịch duy nhất chuyển bất kỳ lỗi nào thành (statusCode, responseBody). Tại đây các lỗi có kiểu ánh xạ sang HTTP status, còn lỗi chưa biết thì trở thành 500 an toàn.
Tiếp theo, thêm middleware mà:
request_idPanic không bao giờ nên đổ stack trace ra client. Trả 500 bình thường với thông điệp chung, và ghi log đầy đủ panic với cùng request_id.
Cuối cùng, thay đổi handlers để chúng trả error thay vì tự viết response. Một wrapper gọi handler, chạy translator, và viết JSON theo dạng chuẩn.
Một checklist ngắn gọn:
Golden tests quan trọng vì chúng khóa hợp đồng. Nếu ai đó sau này thay đổi thông điệp hoặc mã trạng thái, test sẽ fail trước khi client bị bất ngờ.
Giả sử một endpoint: app client tạo bản ghi customer.
POST /v1/customers với JSON như { "email": "[email protected]", "name": "Pat" }. Server luôn trả cùng dạng lỗi và luôn có request_id.
Email bị thiếu hoặc sai định dạng. Client có thể gắn highlight lên trường.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
Email đã tồn tại. Client có thể gợi ý đăng nhập hoặc chọn email khác.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
Một dependency bị down. Client có thể retry với backoff và hiển thị thông điệp nhẹ nhàng.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
Với một hợp đồng duy nhất, client phản ứng nhất quán:
details.fieldsrequest_id như mã hỗ trợVới support, cùng request_id đó là con đường nhanh nhất đến nguyên nhân thực trong logs, mà không phơi bày stack trace hay lỗi DB.
Cách nhanh nhất để làm khách hàng bực là bắt họ đoán. Nếu một endpoint trả { "error": "..." } và endpoint khác trả { "message": "..." }, mọi client biến thành đống trường hợp đặc biệt, và bug ẩn trong nhiều tuần.
Một vài sai lầm thường gặp:
code ổn định để client bám vào.request_id khi thất bại, khiến bạn không thể đối chiếu báo cáo người dùng với cuộc gọi thành công trước đó.Lộ nội bộ là cái bẫy dễ rơi vào nhất. Một handler trả err.Error() vì tiện, rồi một tên constraint hay thông điệp bên thứ ba lọt vào phản hồi production. Giữ thông điệp client ngắn gọn và an toàn, và đặt nguyên nhân chi tiết vào logs.
Dựa vào văn bản thuần (text) là một vấn đề kéo dài. Nếu client phải parse câu tiếng Anh như “email already exists”, bạn không thể thay đổi câu chữ mà không phá vỡ logic. Mã lỗi ổn định cho phép bạn điều chỉnh thông điệp, dịch chúng, và giữ hành vi nhất quán.
Xem mã lỗi như một phần hợp đồng công khai. Nếu phải thay đổi, thêm mã mới và giữ mã cũ hoạt động một thời gian, ngay cả khi cả hai cùng ánh xạ sang cùng một HTTP status.
Cuối cùng, bao gồm cùng một trường request_id trong mọi phản hồi, thành công hay thất bại. Khi người dùng nói “nó từng hoạt động, rồi bị lỗi”, một ID thường cứu được cả giờ đồng hồ dò tìm.
Trước khi release, kiểm tra nhanh sự nhất quán:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Thêm test để handlers không trả mã không xác định.request_id và log nó cho mọi request, bao gồm panic và timeout.Sau đó, kiểm tra vài endpoint thủ công. Kích hoạt lỗi validation, record không tồn tại, và lỗi bất ngờ. Nếu phản hồi khác nhau giữa các endpoint (các trường thay đổi, mã trạng thái lệch, thông điệp tiết lộ quá nhiều), sửa pipeline chung trước khi thêm tính năng.
Quy tắc thực tế: nếu một thông điệp có thể giúp attacker hoặc làm người dùng bối rối, nó thuộc về logs chứ không phải phản hồi.
Viết ra hợp đồng lỗi bạn muốn mọi endpoint tuân theo, ngay cả khi API đã live. Một hợp đồng chung (status, mã lỗi ổn định, thông điệp an toàn, và request_id) là cách nhanh nhất để làm lỗi dễ dự đoán cho client.
Rồi di chuyển dần. Giữ handlers hiện tại, nhưng dẫn lỗi của chúng qua một mapper chung biến lỗi nội bộ thành dạng phản hồi công khai của bạn. Cách này cải thiện tính nhất quán mà không phải rewrite lớn mạo hiểm, và ngăn các endpoint mới tự phát minh định dạng.
Giữ một catalog mã lỗi nhỏ và coi nó là một phần API. Khi ai đó muốn thêm mã mới, rà soát nhanh: nó thực sự mới không, tên có rõ ràng không, và nó ánh xạ tới HTTP status đúng không?
Thêm vài test bắt drift:
request_id.error.code tồn tại và thuộc catalog.error.message an toàn và không bao gồm chi tiết nội bộ.Nếu bạn xây backend Go từ đầu, khóa hợp đồng sớm sẽ hữu ích. Ví dụ, Koder.ai (koder.ai) có chế độ planning cho phép định nghĩa các quy ước như schema lỗi và catalog mã lỗi trước, rồi giữ handlers thẳng hàng khi API phát triển.
Sử dụng một dạng JSON duy nhất cho mọi phản hồi lỗi trên tất cả các endpoint. Một mẫu mặc định hữu dụng là request_id ở cấp cao nhất cùng một đối tượng error gồm code, message, và details tùy chọn để client có thể phân tích và xử lý một cách tin cậy.
Trả error.message dưới dạng câu ngắn, an toàn cho người dùng và lưu nguyên nhân thực sự trong log máy chủ. Không trả lỗi cơ sở dữ liệu thô, stack trace, hostname nội bộ hay thông điệp từ nhà cung cấp, ngay cả khi điều đó có vẻ hữu ích khi đang phát triển.
Dùng một error.code ổn định cho logic máy và để HTTP status mô tả hạng mục chung. Client nên phân nhánh theo error.code (ví dụ ALREADY_EXISTS) và coi status như hướng dẫn (ví dụ 409 nghĩa là xung đột trạng thái).
Dùng 400 khi request không thể phân tích hoặc giải thích đáng tin cậy (JSON sai, kiểu dữ liệu sai). Dùng 422 khi request có thể parse nhưng vi phạm quy tắc nghiệp vụ (email không hợp lệ, mật khẩu quá ngắn).
Dùng 409 khi input hợp lệ nhưng không thể áp dụng vì xung đột với trạng thái hiện tại (email đã tồn tại, version mismatch). Dùng 422 cho validation ở mức trường dữ liệu, nơi thay đổi giá trị có thể khắc phục mà không cần thay đổi trạng thái server.
Tạo một bộ lỗi có kiểu nhỏ (validation, not found, conflict, unauthorized, internal) và để handlers trả chúng. Sau đó dùng một bộ dịch chung để ánh xạ các kiểu đó sang status HTTP và mẫu JSON chuẩn.
Luôn trả request_id trong mọi phản hồi (thành công hay lỗi) và ghi nó trên mọi dòng log. Khi client báo lỗi, một request_id thường là đủ để tìm chính xác luồng thực thi và nguyên nhân trong log.
Chỉ trả 200 khi thao tác thành công; dùng 4xx/5xx cho lỗi. Giấu lỗi sau 200 khiến client phải parse nội dung body và tạo hành vi không nhất quán giữa các endpoint.
Không retry cho mặc định với 400, 401, 403, 404, 409, 422 vì retry thường không giúp nếu không thay đổi gì. Cho phép retry với 503, và đôi khi 429 sau khi chờ; nếu hỗ trợ idempotency keys thì retry an toàn hơn cho POST khi lỗi tạm thời.
Khóa hợp đồng bằng vài bài test “golden” khẳng định status, error.code, và sự hiện diện của request_id. Thêm mã lỗi mới mà không phá vỡ nghĩa cũ, và chỉ thêm các trường tùy chọn để client cũ vẫn hoạt động.