Idempotency-Key: retry POST /orders mà không tạo đơn thứ hai Trung cấp
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”Người mua bấm Thanh toán trên mobile app. App gửi POST /orders cho một đôi giày, qty: 1. API tạo order 1 và trừ tiền thẻ, nhưng response 201 bị rơi trên đường 4G chập chờn.
Hết timeout 10 giây (số minh hoạ), app không biết đơn đã được tạo hay chưa.
Nếu app gửi lại đúng POST đó, API coi là request mới và tạo order 2. Nếu app bỏ cuộc, người mua có thể bấm lại và kết quả y hệt. Cả hai lựa chọn đều không an toàn.
Câu hỏi: API làm sao cho client retry POST mà vẫn chỉ ra đúng một đơn? Webhook và consumer đã có bài dedupe riêng; bài này nói về tầng HTTP API.
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Client tạo đơn hoặc thanh toán và có thể retry sau timeout | Idempotency-Key + lưu response |
Key theo user + endpoint, có fingerprint, TTL dài hơn cửa sổ retry |
| Client tự chọn được URI của resource | PUT /orders/{clientId} |
PUT vốn đã idempotent |
| Việc xử lý chậm (gọi payment gateway) | Claim hai pha, 409 cho bản trùng chạy song song |
Có lease để thu hồi key kẹt in_progress |
| Việc xử lý ngắn, chỉ đụng PostgreSQL | Claim + effect trong một transaction | Bản trùng chờ trên unique index thay vì nhận 409 |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Vì sao POST không idempotent
Phần tiêu đề “Vì sao POST không idempotent”RFC 9110 §9.2.2 gọi một method là idempotent khi nhiều request giống hệt nhau có cùng hiệu ứng dự định như một request. PUT, DELETE và các method safe (GET, HEAD, OPTIONS, TRACE) là idempotent; POST thì không, PATCH (RFC 5789) cũng không.
Vì vậy client SHOULD NOT tự động retry request non-idempotent, trừ khi biết được request đó thực chất idempotent hoặc chưa từng được áp dụng. Idempotency-Key cung cấp hiểu biết đó: một hợp đồng server công bố cho từng endpoint.
Header Idempotency-Key
Phần tiêu đề “Header Idempotency-Key”Header này là Internet-Draft của nhóm IETF httpapi, chưa phải RFC: draft-ietf-httpapi-idempotency-key-header-08 (22/10/2026). API của Stripe đã dùng đúng tên header này. Draft quy định:
| Chủ đề | Nội dung draft-08 (diễn giải) |
|---|---|
| Cú pháp | Structured Field Item, giá trị phải là String: Idempotency-Key: "6f1c2a8e-..." |
| Giá trị key | Duy nhất, không được dùng lại với payload khác; khuyến nghị UUID hoặc giá trị ngẫu nhiên tương tự |
| Fingerprint | Tuỳ chọn: server có thể ghép key với hash của payload |
| Hết hạn | Resource nên định nghĩa chính sách hết hạn và công bố trong tài liệu |
| Thiếu key | 400 khi thao tác đã được tài liệu hoá là bắt buộc header |
| Cùng key, payload khác | 422 |
| Bản trùng tới khi bản đầu còn đang xử lý | 409 |
Stripe gửi giá trị không có dấu nháy và cho phép tối đa 255 ký tự. Draft còn có thể đổi; theo tài liệu của server.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”POST + K1Người dùng bấm Thanh toán một lần. App tạo key K1 cho ý định này và gửi POST /orders.
Trạng thái sau từng bước:
- Request: app giữ key
K1cho lần bấm này; database trống. - Claim: row
(7, POST /orders, K1, in_progress); primary key chặn row thứ hai. - Effect: order 1, event outbox và
response_code = 201commit cùng nhau. - Mất response: trạng thái server đã chốt; chỉ client là không chắc.
- Retry: claim trả 0 rows; API đọc row đã có.
- Replay: fingerprint khớp, status
completed: trả lại201đã lưu. Vẫn một đơn.
Claim nguyên tử trên PostgreSQL 17
Phần tiêu đề “Claim nguyên tử trên PostgreSQL 17”Phạm vi (scope) là (user_id, endpoint, key), nên hai user trùng UUID không đụng nhau. Fingerprint là SHA-256 của method, path và body JSON đã chuẩn hoá. Claim là một câu lệnh; SELECT rồi mới INSERT sẽ bị race, như bài upsert với ON CONFLICT đã chỉ ra.
CREATE TABLE idempotency_keys ( user_id bigint NOT NULL, endpoint text NOT NULL, idem_key text NOT NULL, request_hash text NOT NULL, status text NOT NULL CHECK (status IN ('in_progress', 'completed')), response_code int, response_body jsonb, locked_until timestamptz, -- lease cho row in_progress expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours', PRIMARY KEY (user_id, endpoint, idem_key));
INSERT INTO idempotency_keys (user_id, endpoint, idem_key, request_hash, status, locked_until)VALUES ($1, $2, $3, $4, 'in_progress', now() + interval '30 seconds')ON CONFLICT (user_id, endpoint, idem_key) DO NOTHINGRETURNING idem_key;Trả về một row nghĩa là request này sở hữu key. Không row nào nghĩa là request khác tới trước; handler đọc row đó rồi quyết định:
| Row đang có | Response | Vì sao |
|---|---|---|
request_hash khác |
422 |
Key bị dùng lại cho ý định khác (draft-08) |
Cùng hash, in_progress |
409 (hoặc chờ) |
Lần đầu chưa xong |
Cùng hash, completed |
Status + body đã lưu | Retry nhận đúng thứ lần đầu nhận |
Bản trùng chạy song song: 409 hay chờ
Phần tiêu đề “Bản trùng chạy song song: 409 hay chờ”Retry có thể tới khi request đầu vẫn đang gọi payment gateway. Kết quả phụ thuộc vào thời điểm claim được commit.
A claim K2 trong một transaction ngắn, commit status in_progress, rồi gọi gateway.
- Hai pha (như hình): claim commit riêng, nên B thấy
in_progressngay và nhận409. Không giữ lock nào trong lúc gọi gateway. - Một transaction: claim chưa commit cho tới khi effect commit.
INSERT ... ON CONFLICTcủa B chờ trên unique index (demo thấywait_event = transactionid), rồi trả 0 rows. Nếu A rollback, insert của B thành công.
Cách chờ hợp với việc ngắn, chỉ đụng database. Với lời gọi ra ngoài chậm, nó giữ transaction mở suốt lời gọi; nên dùng hai pha và 409.
Vòng đời của key
Phần tiêu đề “Vòng đời của key”Đang tải diagram…
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Lưu gì: status code, body và header client cần (
Location).jsonbsắp xếp lại key (xem dòng 2 của demo); dùngtextnếu cần replay giống từng byte. - Lưu kết quả nào: lưu khi việc xử lý đã bắt đầu. Lỗi validation
400xảy ra trước claim, nên client sửa rồi gửi lại được. Stripe v1 theo đúng quy tắc này và replay cả500đã lưu. - TTL: dài hơn cửa sổ retry lâu nhất của client, tính cả hàng đợi offline. Stripe v1 có thể xoá key sau 24 giờ; key đã xoá sẽ tạo request mới. Purge theo
expires_at, có index. - Kẹt
in_progress: crash sau claim để lại row tớilocked_until; sau đó retry có thể tiếp quản bằngUPDATE ... WHERE locked_until < now(). - Side effect bên ngoài: tiếp quản có thể gọi gateway lần nữa, nên truyền cho gateway một key suy ra từ key gốc. Event đi qua transactional outbox trong cùng transaction với response.
- Lỗi:
400,409,422trả dạng Problem Details vớitypeổn định, như ví dụ trong draft-08.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo chạy handler trên PostgreSQL 17 với hai connection:
docker compose -f examples/docker-compose.yml up -d postgresnpx --prefix examples ts-node examples/src/idempotency-key/demo.tsOutput (PostgreSQL 17.11)
1. first POST -> 201 {"orderId":1,"sku":"SHOE-42","qty":1,"status":"created"}2. retry, same key + same body -> 201 (replayed) {"qty":1,"sku":"SHOE-42","status":"created","orderId":1}3. same key, qty changed to 2 -> 422 {"type":"https://api.example.org/problems/idempotency-key-reused",...}4. same key, another user -> 201 {"orderId":2,...}6b. retry while A in progress -> 409 {"type":"https://api.example.org/problems/request-in-progress",...}6a. A finishes -> 201 {"orderId":3,...}6c. retry after A finished -> 201 (replayed) {...,"orderId":3}7. purged expired keys: 17b. late retry after expiry -> 201 {"orderId":4,...}orders now: 48. B while A uncommitted: { wait_event_type: 'Lock', wait_event: 'transactionid' }8. B after A COMMIT: rowCount = 0 (waited ~300 ms)Dòng 2 và 6c là replay; dòng 4 cho thấy phạm vi theo user. Dòng 7b là bẫy TTL: retry tới sau khi purge tạo đơn thứ hai. Output đã rút gọn bằng .... Demo chưa có tiếp quản lease, lời gọi gateway thật và timeout request.
Trade-offs
Phần tiêu đề “Trade-offs”Idempotency-Key mang lại gì
- Client retry
POSTsau timeout mà không tạo trùng đơn hay trừ tiền hai lần. - Retry nhận lại kết quả gốc, gồm cả ID vừa tạo.
422làm lộ bug client dùng lại key.
Chi phí và giới hạn
- Thêm một lần ghi mỗi request; bảo đảm hết hiệu lực khi qua TTL.
- Hai pha cần lease và logic tiếp quản cho request bị crash.
- Lời gọi ra ngoài vẫn cần idempotency riêng (key cho gateway, outbox).
Checklist production
Phần tiêu đề “Checklist production”- Tài liệu hoá endpoint nào bắt buộc
Idempotency-Key, định dạng key, TTL và các loại lỗi. - Phạm vi unique theo caller và endpoint:
PRIMARY KEY (user_id, endpoint, idem_key). - Claim bằng
INSERT ... ON CONFLICT DO NOTHING RETURNING; khôngSELECTrồi mớiINSERT. - Lưu fingerprint của method, path và body đã chuẩn hoá; lệch thì trả
422. - Commit effect nghiệp vụ, row outbox và response đã lưu trong một transaction.
- Chọn
409hoặc chờ cho bản trùng song song; nếu409, thêm lease cho rowin_progress. - Đặt TTL dài hơn cửa sổ retry lâu nhất của client; purge key hết hạn theo lô.
- Cảnh báo theo tỉ lệ
409và422cho từng phiên bản client.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Phần lớn bug phía client đến từ việc sinh key sai thời điểm.
Một key cho một ý định của người dùng, dùng lại khi retry
Hay gặp
// Key mới mỗi lần thử: server thấy hai request khác nhauawait retry(() => api.post('/orders', cart, { headers: { 'Idempotency-Key': crypto.randomUUID() } }));Nên viết
const key = crypto.randomUUID(); // tạo một lần, khi người dùng bấm Thanh toánawait retry(() => api.post('/orders', cart, { headers: { 'Idempotency-Key': key } }));Vì sao
Key sinh trong vòng retry không dedupe được gì. Gắn key với ý định, và lưu xuống máy nếu retry phải sống qua lần khởi động lại app.
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
“Unique constraint trên orders là đủ” |
Retry nhận lỗi, không nhận order ID | Lưu và replay response đầu tiên |
| “Key chỉ cần để trong Redis” | Key và đơn commit riêng; crash ở giữa làm hỏng dedupe | Key và effect trong cùng một transaction database |
| “TTL 1 giờ là thoải mái” | Hàng đợi offline retry muộn và tạo trùng | TTL dài hơn cửa sổ retry lâu nhất |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Hai request cùng key tới cách nhau 50 ms. Mỗi request nhận gì với thiết kế hai pha, và với thiết kế một transaction?
- Vì sao không được lưu lỗi validation
400dưới key? - Process crash sau khi gateway đã trừ tiền nhưng trước
COMMIT. Điều gì ngăn lần trừ tiền thứ hai khi tiếp quản?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- draft-ietf-httpapi-idempotency-key-header-08 — IETF: cú pháp, fingerprint, các trường hợp
400/409/422. - RFC 9110 §9.2.2 Idempotent Methods: method nào idempotent và khi nào client được retry.
- PostgreSQL 17 — INSERT:
ON CONFLICT DO NOTHINGvàRETURNING. - Stripe — Idempotent requests: độ dài key, xoá sau 24 giờ, kết quả nào được lưu.
- RFC 9457 — Problem Details: định dạng body lỗi cho
409và422.