Error contract (RFC 9457) và thiết kế Idempotency-Key

4 câu hỏi~25 phútTừ khóa: Problem Details, type, error code, traceId, Idempotency-Key, fingerprint, TTL, 422, retry, backoff, jitter

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 đề

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 } ]
}
FieldCho aiỔn định?
type / codeMáy (switch-case)Có, là một phần contract
title, detailNgười (log, màn hình dev)Có thể đổi
traceIdSupport tra logMỗi request khác
errors[]Form, highlight fieldCó

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 transactionRedis
Nhất quán với orderTuyệt đốiCó khe hở giữa Redis và DB
TTLJob dọnTự hết hạn
Tốc độĐủNhanh
Hợp vớiTiền, orderThao tác ít rủi ro, tải lớn

7. Khi nào nên dùng / không nên dùng

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.

ResponseRetry?
Timeout, connection resetCó (cùng key)
429Có, chờ theo Retry-After
502, 503, 504Có, backoff
500Có giới hạn, chỉ khi idempotent/có key
409 REQUEST_IN_PROGRESSCó, chờ chút
400, 401, 403, 404, 422Khô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:

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êm code ổn định cho máy, traceId cho 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-readable code, a traceId for support and a per-field errors array. 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


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.