Lỗi API với RFC 9457: status, type và detail không được lẫn vai Cơ bản
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”Client gọi POST /orders/42/payments nhưng đơn đã được thanh toán. Hai cách trả lời:
Sai: HTTP 200 kèm cờ lỗi trong body
HTTP/1.1 200 OKContent-Type: application/json
{ "success": false, "message": "already paid" }SDK, metric và cache thấy 200 nên coi là thành công. Client khác parse message để quyết định retry, nên đổi câu chữ là hỏng logic.
Đúng: status đúng lớp lỗi + application/problem+json
HTTP/1.1 409 ConflictContent-Type: application/problem+jsonContent-Language: en
{ "type": "https://api.example.org/problems/order-already-paid", "title": "Order already paid", "status": 409, "detail": "Order 42 was paid on 2026-10-17; a second payment was rejected.", "instance": "/orders/42/payments/attempts/9f3c", "orderId": 42}| Trường | Vai trò | Client dùng để |
|---|---|---|
409 (dòng status) |
Lớp kết quả theo HTTP | SDK, retry, metric, cache |
type |
Định danh loại lỗi, ổn định | Phân nhánh logic |
title |
Tên ngắn của loại lỗi | Hiển thị, không branch |
detail |
Mô tả lần xảy ra này | Người đọc |
instance |
Định danh lần xảy ra | Tra cứu với support |
orderId |
Extension member | Dữ liệu có cấu trúc, có document |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”RFC 9457 (2023) obsoletes RFC 7807. Định dạng JSON giữ nguyên; RFC 9457 bổ sung:
- Registry HTTP Problem Types của IANA cho các
typedùng chung (mục 4.2). - Làm rõ cách xử lý nhiều lỗi trong một response: nên trả lỗi liên quan hoặc khẩn cấp nhất, đừng bịa type “batch” chung chung (mục 3).
- Hướng dẫn cho
typeURI không dereference được (mục 3.1.1).
Hai quy tắc dễ bỏ sót:
typevắng thì mặc địnhabout:blank. Khi đótitleSHOULD bằng HTTP status phrase (ví dụNot Foundcho 404), có thể bản địa hóa (mục 4.2.1). Nghĩa là chỉ có status, không có ý nghĩa nghiệp vụ riêng.titlekhông đổi theo từng lần xảy ra (ngoại trừ localization); phần thay đổi theo occurrence thuộcdetail.
status trong body chỉ là bản sao advisory; status trên wire mới là thứ giao thức dùng. Tạo cả hai từ một biến duy nhất để không lệch.
POSTClient gửi thao tác thanh toán. API nhận order ID và xác thực người gọi.
Chọn status và hướng dẫn retry
Phần tiêu đề “Chọn status và hướng dẫn retry”| Status | Dùng khi | Client retry? |
|---|---|---|
400 |
Request sai cú pháp, thiếu tham số | Không, sửa request |
401 |
Chưa xác thực / token hết hạn | Sau khi làm mới credential |
403 |
Đã xác thực nhưng không đủ quyền | Không |
404 |
Resource không tồn tại (hoặc che sự tồn tại, xem BOLA) | Không |
409 |
Xung đột với trạng thái hiện tại | Không, đọc lại trạng thái rồi quyết định |
422 |
Cú pháp đúng nhưng nội dung không qua validation | Không, sửa dữ liệu |
429 |
Vượt rate limit | Có, sau Retry-After nếu server gửi |
503 |
Quá tải hoặc bảo trì tạm thời | Có, tôn trọng Retry-After, backoff theo retry & jitter |
500 |
Lỗi không lường trước | Chỉ với thao tác idempotent hoặc có idempotency key |
504 |
Gateway hết thời gian chờ upstream | Trạng thái không biết: tra cứu trước khi gửi lại (timeout budget) |
Retry còn phụ thuộc method và idempotency key; bảng chỉ là mặc định hợp lý cho client. Chọn status theo ngữ nghĩa, không trả 200 cho mọi lỗi chỉ để frontend dễ parse.
Lỗi validation nhiều field
Phần tiêu đề “Lỗi validation nhiều field”Dùng extension member errors với JSON Pointer (đúng kiểu ví dụ trong RFC):
{ "type": "https://api.example.org/problems/validation-error", "title": "Your request is not valid.", "status": 422, "errors": [ { "pointer": "#/amount", "code": "must_be_positive", "detail": "must be a positive integer" }, { "pointer": "#/currency", "code": "unsupported", "detail": "must be VND or USD" } ]}Client hiển thị theo pointer và code; detail chỉ để người đọc. Document schema của errors như một phần của contract. Spring có ProblemDetail và ASP.NET Core có ProblemDetails làm sẵn phần khung; Node thường tự viết một exception filter.
Khi nào dùng
Phần tiêu đề “Khi nào dùng”| Dùng | Không cần |
|---|---|
| API công khai hoặc nhiều client (web, mobile, partner) | Script nội bộ một client, không cần phân nhánh lỗi |
| Cần client phân biệt các lỗi nghiệp vụ | Chỉ cần status code chuẩn, about:blank là đủ |
| Muốn thống nhất lỗi giữa nhiều service | Kênh không phải HTTP (GraphQL, message) có contract riêng |
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo (chỉ dựng object response, không có HTTP server):
npx --prefix examples ts-node examples/src/problem-details/problem-demo.tsĐổi status đầu vào thành 400: status ngoài và body.status đổi cùng nhau vì helper dùng một nguồn. Thử đặt type thành /orders/42: client sẽ thấy type khác nhau cho cùng một lỗi, nên đừng gắn ID occurrence vào type.
Ở ranh giới API, viết một lớp chuyển domain error (ví dụ OrderAlreadyPaid) thành problem. Domain service không cần biết 409. Test cả Content-Type, status, body và việc không lộ dữ liệu.
Trade-offs
Phần tiêu đề “Trade-offs”Định dạng chung giúp gì
- Client phân biệt lỗi theo
typeổn định thay vì parse thông điệp tự do. - HTTP status và body có vai trò rõ, hỗ trợ SDK và quan sát vận hành.
- Extension members cho phép thêm dữ liệu có cấu trúc.
Điều vẫn phải thiết kế
- Phải duy trì registry problem type và tương thích contract.
- Không tự chọn status đúng, không tự giải quyết retry hay authorization.
detailquá nhiều dễ rò rỉ; quá ít khó hỗ trợ người dùng.
Checklist production
Phần tiêu đề “Checklist production”- Status trên wire đúng lớp lỗi; không bao giờ
200kèmsuccess: false. Content-Type: application/problem+json;body.statussinh từ cùng biến với status thật.typeổn định, không chứa ID occurrence; mỗitypecó tài liệu, retryable và hành động người dùng.errorsvalidation cópointer+codeổn định.detailkhông chứa stack trace, SQL, token hay dữ liệu của người khác; chi tiết nằm trong log, tra bằng request ID (correlation ID).429/503kèmRetry-After.- Test contract qua HTTP thật (header, status, body).
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Đổi câu chữ detail, client có nên đổi logic không? (2) Vì sao type không chứa orderId? (3) Với about:blank, title nên là gì?