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

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

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.

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

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

Mất response 201, retry với cùng key vẫn chỉ ra một đơn
Mất response 201, retry với cùng key vẫn chỉ ra một đơnBước 1/6: Ngườ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.Mobile appkey K1Orders APIidempotency_keysorders + outboxPOST + K1
Mobile app → Orders APIPOST + K1
Bước 1/6

Ngườ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:

  1. Request: app giữ key K1 cho lần bấm này; database trống.
  2. Claim: row (7, POST /orders, K1, in_progress); primary key chặn row thứ hai.
  3. Effect: order 1, event outbox và response_code = 201 commit cùng nhau.
  4. Mất response: trạng thái server đã chốt; chỉ client là không chắc.
  5. Retry: claim trả 0 rows; API đọc row đã có.
  6. Replay: fingerprint khớp, status completed: trả lại 201 đã lưu. Vẫn một đơn.

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 NOTHING
RETURNING 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

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.

Claim hai pha: retry nhận 409 khi A đang chạy, rồi nhận 201 đã lưu
Claim hai pha: retry nhận 409 khi A đang chạy, rồi nhận 201 đã lưuBước 1/5: A claim K2 trong một transaction ngắn, commit status in_progress, rồi gọi gateway.thời gianđang gọi payment gateway …claim K2in_progress, đã commitRequest APOST đầu, key K2Request Bretry, key K2
Bước 1/5

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_progress ngay và nhận 409. 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 CONFLICT của B chờ trên unique index (demo thấy wait_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.

Đang tải diagram…

  • Lưu gì: status code, body và header client cần (Location). jsonb sắp xếp lại key (xem dòng 2 của demo); dùng text nế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 400 xả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ới locked_until; sau đó retry có thể tiếp quản bằng UPDATE ... 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, 422 trả dạng Problem Details với type ổn định, như ví dụ trong draft-08.

Demo chạy handler trên PostgreSQL 17 với hai connection:

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
npx --prefix examples ts-node examples/src/idempotency-key/demo.ts
Output (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: 1
7b. late retry after expiry -> 201 {"orderId":4,...}
orders now: 4
8. 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.

Idempotency-Key mang lại gì

  • Client retry POST sau 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.
  • 422 là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).
  1. 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.
  2. Phạm vi unique theo caller và endpoint: PRIMARY KEY (user_id, endpoint, idem_key).
  3. Claim bằng INSERT ... ON CONFLICT DO NOTHING RETURNING; không SELECT rồi mới INSERT.
  4. Lưu fingerprint của method, path và body đã chuẩn hoá; lệch thì trả 422.
  5. Commit effect nghiệp vụ, row outbox và response đã lưu trong một transaction.
  6. Chọn 409 hoặc chờ cho bản trùng song song; nếu 409, thêm lease cho row in_progress.
  7. Đặ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ô.
  8. Cảnh báo theo tỉ lệ 409 và 422 cho từng phiên bản client.

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 nhau
await 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án
await 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
  1. 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?
  2. Vì sao không được lưu lỗi validation 400 dưới key?
  3. 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?