Bỏ qua để đến nội dung
Knowledge Hub

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

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 OK
Content-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 Conflict
Content-Type: application/problem+json
Content-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

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 type dù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 type URI không dereference được (mục 3.1.1).

Hai quy tắc dễ bỏ sót:

  • type vắng thì mặc định about:blank. Khi đó title SHOULD bằng HTTP status phrase (ví dụ Not Found cho 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.
  • title không đổi theo từng lần xảy ra (ngoại trừ localization); phần thay đổi theo occurrence thuộc detail.

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.

Một lỗi nghiệp vụ đi từ HTTP status đến problem type và log
Một lỗi nghiệp vụ đi từ HTTP status đến problem type và logBước 1/4: Client gửi thao tác thanh toán. API nhận order ID và xác thực người gọi.ClientAPILogsPOST
Client → APIPOST
Bước 1/4

Client gửi thao tác thanh toán. API nhận order ID và xác thực người gọi.

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.

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.

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

Chạy demo (chỉ dựng object response, không có HTTP server):

Terminal window
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.

Đị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.
  • detail quá nhiều dễ rò rỉ; quá ít khó hỗ trợ người dùng.
  1. Status trên wire đúng lớp lỗi; không bao giờ 200 kèm success: false.
  2. Content-Type: application/problem+json; body.status sinh từ cùng biến với status thật.
  3. type ổn định, không chứa ID occurrence; mỗi type có tài liệu, retryable và hành động người dùng.
  4. errors validation có pointer + code ổn định.
  5. detail khô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).
  6. 429/503 kèm Retry-After.
  7. Test contract qua HTTP thật (header, status, body).

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ì?