Error contract (RFC 9457) và thiết kế Idempotency-Key
Tiếp đi: lỗi và retry. Partner phàn nàn hai chuyện: lỗi trả về khó xử lý tự động, và có lần họ retry POST /orders nên bị tạo 2 order.
Cách gom lỗi về ProblemDetail trong Spring đã có ở bài 4.2.7. Idempotency ở mức consumer đã có ở bài message queue và payment. Bài này nhìn từ phía contract của public API.
1. Bài toán
Partner ──POST /orders──> shop-app (tạo order OK)
<── timeout ───── (response mất)
Partner ──POST /orders──> shop-app (tạo order lần 2!)
Lỗi thì lúc là {"error":"bad"}, lúc là text, partner phải parse message bằng regex.
2. Vấn đề
- Message cho người đọc thay đổi theo ngôn ngữ, sửa câu chữ là code partner gãy.
- Client không biết lỗi nào nên retry, lỗi nào phải dừng.
- POST không idempotent: retry = tạo trùng, trừ tiền trùng.
3. Giải pháp
Error contract theo RFC 9457 (Problem Details), thay RFC 7807:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.shop.example/problems/out-of-stock",
"title": "Out of stock",
"status": 422,
"detail": "SKU SHOE-42 chỉ còn 1, yêu cầu 3",
"instance": "/orders",
"code": "OUT_OF_STOCK",
"traceId": "4bf92f3577b34da6",
"errors": [ { "field": "items[0].quantity", "code": "MAX_AVAILABLE", "max": 1 } ]
}
| Field | Cho ai | Ổn định? |
|---|---|---|
type / code | Máy (switch-case) | Có, là một phần contract |
title, detail | Người (log, màn hình dev) | Có thể đổi |
traceId | Support tra log | Mỗi request khác |
errors[] | Form, highlight field | Có |
Không bao giờ trả stack trace, SQL, tên class.
Idempotency-Key: client sinh UUID cho mỗi ý định (một lần bấm "Đặt hàng"), gửi lại đúng key khi retry. Server nhớ kết quả theo key.
4. Code từng bước
Bước 1: bảng lưu key
CREATE TABLE idempotency_key (
client_id VARCHAR(64) NOT NULL,
idem_key VARCHAR(100) NOT NULL,
request_hash CHAR(64) NOT NULL, -- SHA-256 của method+path+body
status VARCHAR(16) NOT NULL, -- IN_PROGRESS / COMPLETED
response_code INT,
response_body JSONB,
expires_at TIMESTAMP NOT NULL, -- TTL ~24h
PRIMARY KEY (client_id, idem_key)
);
Bước 2: quy tắc
Key mới -> insert IN_PROGRESS, xử lý, lưu response
Key cũ, cùng hash, COMPLETED -> trả lại response đã lưu (không xử lý lại)
Key cũ, cùng hash, IN_PROGRESS -> 409 Conflict (+ Retry-After)
Key cũ, KHÁC hash -> 422 (dùng lại key cho request khác)
Thiếu key ở endpoint bắt buộc -> 400
Bước 3: Code Java
public ResponseEntity<?> createOrder(String clientId, String key, CreateOrderRequest req) {
String hash = sha256(canonicalJson(req));
boolean inserted = repo.tryInsert(clientId, key, hash, Instant.now().plus(Duration.ofHours(24)));
if (!inserted) {
IdempotencyRecord r = repo.find(clientId, key);
if (!r.requestHash().equals(hash)) throw new ProblemException(422, "IDEMPOTENCY_KEY_REUSED");
if (r.inProgress()) throw new ProblemException(409, "REQUEST_IN_PROGRESS");
return ResponseEntity.status(r.responseCode())
.header("Idempotent-Replayed", "true").body(r.responseBody());
}
try {
OrderDto order = orderService.create(req); // transaction nghiệp vụ
repo.complete(clientId, key, 201, order);
return ResponseEntity.created(URI.create("/orders/" + order.id())).body(order);
} catch (RuntimeException e) {
repo.delete(clientId, key); // lỗi tạm thời: cho phép retry cùng key
throw e;
}
}
tryInsert dựa vào primary key: hai request song song thì chỉ một cái insert được. Tốt nhất ghi complete trong cùng transaction với order. Lỗi 4xx nghiệp vụ (hết hàng) nên lưu lại như kết quả; lỗi 5xx tạm thời thì xóa để retry.
Bước 4: test
K=$(uuidgen)
curl -i -X POST https://api.shop.example/v1/orders -H "Idempotency-Key: $K" \
-H "Authorization: Bearer $TOKEN" -d @order.json # 201
curl -i -X POST ... -H "Idempotency-Key: $K" -d @order.json # 201, Idempotent-Replayed: true
curl -i -X POST ... -H "Idempotency-Key: $K" -d @other.json # 422
5. Cách hoạt động
Client API DB
│ POST + Key=K ──────────> │ INSERT K IN_PROGRESS ──> │ OK
│ │ create order ─────────> │
│ (timeout) x────────── │ UPDATE K COMPLETED ────> │
│ POST + Key=K ──────────> │ INSERT K ─────────────> │ duplicate!
│ │ SELECT K: same hash, done│
│ <── 201 (replayed) ────── │ │
6. So sánh: nơi lưu key
| Bảng DB cùng transaction | Redis | |
|---|---|---|
| Nhất quán với order | Tuyệt đối | Có khe hở giữa Redis và DB |
| TTL | Job dọn | Tự hết hạn |
| Tốc độ | Đủ | Nhanh |
| Hợp với | Tiền, order | Thao tác ít rủi ro, tải lớn |
7. Khi nào nên dùng / không nên dùng
- Bắt buộc Idempotency-Key: tạo order, thanh toán, refund, gửi tin nhắn tính phí.
- Không cần: GET, PUT, DELETE (đã idempotent).
- Key theo
client_id, không global, để client này không đụng key client khác. - Không thay thế unique constraint nghiệp vụ (vd
partner_order_refunique).
Vậy SDK cho partner nên retry những lỗi nào, bao nhiêu lần? Lỗi 500 retry luôn được không?
Gần đúng, nhưng phải phân loại. Retry chỉ an toàn khi request idempotent hoặc có Idempotency-Key.
| Response | Retry? |
|---|---|
| Timeout, connection reset | Có (cùng key) |
| 429 | Có, chờ theo Retry-After |
| 502, 503, 504 | Có, backoff |
| 500 | Có giới hạn, chỉ khi idempotent/có key |
| 409 REQUEST_IN_PROGRESS | Có, chờ chút |
| 400, 401, 403, 404, 422 | Không, sửa request |
delay = min(cap, base * 2^attempt) * random(0.5..1.0) // exponential backoff + jitter
attempt: 0 -> ~0.2s, 1 -> ~0.4s, 2 -> ~0.8s ... tối đa 3-5 lần
Jitter tránh hàng nghìn client retry cùng một giây sau sự cố. Chi tiết 429 xem bài rate limiting.
Mình có nên tạo error code riêng cho mọi lỗi validation không, kiểu EMAIL_TOO_LONG, EMAIL_INVALID_DOMAIN...? Danh sách sẽ dài lắm.
Không cần. Chia hai tầng:
- Top-level
code: ít, ổn định, client dùng để quyết định hành động:VALIDATION_FAILED,OUT_OF_STOCK,PAYMENT_DECLINED,ORDER_ALREADY_SHIPPED. errors[]: chi tiết theo field với code chung (REQUIRED,TOO_LONG,INVALID_FORMAT) + tham số (max).
Thêm code mới là thay đổi tương thích nếu client có nhánh "code lạ thì xử lý như lỗi chung". Ghi rõ điều đó trong contract (tolerant reader, 11.4). Đổi tên code cũ là breaking change.
Phỏng vấn hỏi thiết kế error response và Idempotency-Key thì trả lời sao?
Q1. Thiết kế error response cho public API?
🇻🇳 Tiếng Việt:
Em theo RFC 9457 Problem Details với
application/problem+json:type,title,status,detail,instance, thêmcodeổn định cho máy,traceIdcho support vàerrors[]theo field. Client switch theo code, không theo message. Không lộ stack trace hay SQL.
🇬🇧 English:
I follow RFC 9457 Problem Details with
application/problem+json: type, title, status, detail and instance, plus a stable machine-readablecode, atraceIdfor support and a per-fielderrorsarray. Clients branch on the code, never on the message, and we never leak stack traces or SQL.
Q2. Thiết kế Idempotency-Key thế nào?
🇻🇳 Tiếng Việt:
Client gửi UUID mỗi ý định trong header. Server lưu key theo client cùng hash của request, trạng thái và response, TTL khoảng 24 giờ. Key mới thì xử lý; key cũ cùng body thì trả lại response đã lưu; đang xử lý thì 409; cùng key khác body thì 422. Với order và tiền, em lưu key trong cùng transaction DB với dữ liệu nghiệp vụ.
🇬🇧 English:
The client sends a UUID per intent in a header. The server stores the key per client with a request hash, status and the response, with a TTL of about 24 hours. A new key is processed; a repeated key with the same body replays the stored response; one still in progress gets 409; the same key with a different body gets 422. For orders and payments I store the key in the same database transaction as the business data.
Q3. Client nên retry thế nào?
🇻🇳 Tiếng Việt:
Chỉ retry lỗi tạm thời: timeout, 429, 502, 503, 504, và chỉ khi request idempotent hoặc có Idempotency-Key. Dùng exponential backoff có jitter, giới hạn số lần, tôn trọng
Retry-After. Lỗi 4xx khác thì không retry.
🇬🇧 English:
Retry only transient failures such as timeouts, 429, 502, 503 and 504, and only when the request is idempotent or carries an idempotency key. Use exponential backoff with jitter, cap the attempts and honor
Retry-After. Other 4xx errors should not be retried.
Cách nhớ ngắn gọn
- Lỗi: problem+json, code cho máy, detail cho người, traceId cho support.
- Key mới xử lý, key cũ trả lại, đang chạy 409, khác body 422.
- Key theo client, TTL 24h, cùng transaction với dữ liệu.
- Retry: lỗi tạm thời + backoff + jitter + có key.
Tự kiểm tra
Vì sao client không nên parse detail?
Đó là text cho người, có thể đổi câu chữ/ngôn ngữ. Contract cho máy là type/code.
Cùng Idempotency-Key nhưng body khác thì làm gì?
Trả 422: client dùng sai key cho một request khác. Không xử lý.
Hai request cùng key đến song song thì sao?
Primary key cho một cái insert thành công; cái kia thấy IN_PROGRESS và nhận 409.
Lỗi 503 khi tạo order: có xóa bản ghi key không?
Có, lỗi tạm thời thì xóa (hoặc không commit) để client retry cùng key được xử lý lại.
Jitter để làm gì?
Rải đều thời điểm retry, tránh thundering herd đánh sập service vừa hồi phục.