Tác nhân (agent) của bạn đã gọi đến điểm cuối (endpoint) thanh toán. Yêu cầu đã được gửi đi, giao dịch đã được ghi nhận, và sau đó phản hồi bị hết thời gian chờ (timeout) trên đường về. Tác nhân không bao giờ nhận được mã `200`, vì vậy nó đã làm theo những gì bạn chỉ dẫn khi thất bại: nó thử lại. Giờ đây khách hàng đã bị tính phí hai lần, và không có gì trong nhật ký (log) của bạn trông giống như một lỗi.
Đây là chế độ thất bại phân biệt các tác nhân với các máy khách (client) API thông thường. Một người khi nhấp vào “Thanh toán” một lần sẽ thấy một biểu tượng quay tròn và chờ đợi. Một tác nhân trong vòng lặp thử lại sẽ thấy im lặng và thử lại, đôi khi ba hoặc bốn lần liên tiếp, nhanh hơn bất kỳ người nào có thể làm được. Mọi chính sách thử lại bạn thêm vào để làm cho tác nhân đáng tin cậy hơn cũng làm cho các thao tác ghi trùng lặp dễ xảy ra hơn. Cách khắc phục là tính bất biến (idempotency): làm cho một yêu cầu lặp lại tạo ra cùng một kết quả như một yêu cầu duy nhất.
Hướng dẫn này đề cập đến ý nghĩa của tính bất biến ở cấp độ HTTP, cách tạo khóa mà một tác nhân có thể thực sự sử dụng lại, những gì máy chủ phải lưu trữ để tôn trọng chúng, và cách kiểm tra toàn bộ trước khi một khách hàng thực sự bị tính phí hai lần. Nếu bạn chưa đọc bài viết chính của chúng tôi về lý do các tác nhân AI thất bại trong môi trường sản phẩm, thì các thao tác ghi trùng lặp là chế độ thất bại ẩn dưới hầu hết các báo cáo “tác nhân đã thực hiện nó hai lần”.
Apidog xuất hiện trong phần kiểm thử của vấn đề này. Tính bất biến là thứ bạn tích hợp vào API và lớp công cụ của tác nhân. Điều bạn cần sau đó là một cách để gửi cùng một yêu cầu hai lần và chứng minh rằng lần thứ hai không thay đổi gì, đây là một bài kiểm thử bạn có thể lưu và chạy trong CI.
Tại sao các tác nhân thường phá vỡ tính bất biến hơn con người
Ba yếu tố liên quan đến lưu lượng tác nhân làm cho các bản sao trở nên phổ biến.
Đầu tiên là số lượng thử lại. Các framework tác nhân mặc định thử lại một cách mạnh mẽ vì lỗi mạng tạm thời là nguyên nhân phổ biến nhất gây ra một lần chạy bị gián đoạn. Hướng dẫn của chúng tôi về khôi phục lỗi tác nhân giải thích về backoff và circuit breaker, và mọi kỹ thuật trong đó đều làm tăng số lần một yêu cầu cụ thể đến máy chủ của bạn.
Thứ hai là sự mơ hồ của một lần hết thời gian chờ (timeout). Khi một yêu cầu hết thời gian chờ, máy khách không biết gì về việc liệu máy chủ đã xử lý nó hay chưa. Một mã `504` từ một proxy có thể có nghĩa là thao tác ghi chưa bao giờ xảy ra hoặc nó đã xảy ra và phản hồi bị mất. Con người thường kiểm tra trước khi thử lại. Các tác nhân thường không làm vậy, vì “kiểm tra trước” là một lệnh gọi công cụ bổ sung mà mô hình phải quyết định thực hiện.
Thứ ba là vòng lặp. Một tác nhân thất bại trong một tác vụ có thể khởi động lại toàn bộ tác vụ, chứ không chỉ bước bị lỗi. Nếu bước một tạo một đơn hàng và bước bốn thất bại, một lần khởi động lại đơn giản sẽ tạo ra một đơn hàng thứ hai. Đây là điểm mà các tác nhân đa bước khác biệt rõ rệt so với một script: ranh giới thử lại không rõ ràng, và mô hình, chứ không phải mã của bạn, quyết định nơi nó bắt đầu.
Kết hợp những điều đó lại và bạn sẽ thấy hình dáng của vấn đề. Không phải là các tác nhân gửi các yêu cầu sai. Họ gửi các yêu cầu đúng nhiều hơn một lần.
Tính bất biến thực sự đảm bảo điều gì
Một thao tác là bất biến khi thực hiện nó nhiều lần có cùng tác dụng như thực hiện nó một lần. `GET`, `PUT`, và `DELETE` được định nghĩa là bất biến trong RFC 9110, đặc tả ngữ nghĩa HTTP. `POST` thì không, đó chính là lý do tại sao các thao tác nguy hiểm thường là các lệnh gọi `POST`: tạo đơn hàng, gửi tin nhắn, bắt đầu chuyển khoản.
Hai điểm làm rõ sau sẽ giúp tránh nhiều nhầm lẫn.
Tính bất biến không giống với tính an toàn. Một phương thức an toàn không thay đổi bất cứ điều gì. `DELETE` là bất biến nhưng mang tính phá hủy: gọi nó năm lần vẫn xóa tài nguyên, giống như gọi một lần, nhưng tài nguyên vẫn bị mất. Các tác nhân cần hai thuộc tính này được sắp xếp riêng biệt, đây là lập luận trong bài viết của chúng tôi về khóa API có quyền hạn tối thiểu cho tác nhân từ phía thông tin xác thực.
Tính bất biến cũng không giống với phản hồi giống hệt nhau. Lần gọi thứ hai có thể trả về kết quả đã lưu của lần đầu tiên, và nó có thể trả về một mã trạng thái khác. Điều không được thay đổi là trạng thái trên máy chủ. Một giao dịch. Một đơn hàng. Một email.
Khóa bất biến (Idempotency keys): mô hình giúp POST an toàn
Giải pháp tiêu chuẩn là một khóa do máy khách tạo được gửi kèm theo yêu cầu. Máy chủ ghi lại khóa cùng với kết quả, và bất kỳ yêu cầu sau này mang cùng một khóa sẽ trả về kết quả đã ghi thay vì thực hiện lại công việc.
Stripe đã phổ biến tiêu đề này, và tài liệu về tính bất biến của Stripe vẫn là mô tả rõ ràng nhất về ngữ nghĩa. Ngoài ra còn có nỗ lực của IETF nhằm chuẩn hóa nó thành trường tiêu đề Idempotency-Key, điều đáng đọc trước khi bạn tự đặt tên tiêu đề của mình.
Yêu cầu trông như thế này:
POST /v1/payments HTTP/1.1
Host: api.yourservice.com
Authorization: Bearer sk_live_...
Idempotency-Key: 9f2b7c14-6d3a-4b18-9d55-1e2a7c0b4f31
Content-Type: application/json
{
"amount": 4900,
"currency": "usd",
"customer_id": "cus_8812",
"description": "Pro plan, August"
}
Khóa là một UUID. Nó không có ý nghĩa gì đối với máy chủ ngoài “đây là cùng một thao tác logic.” Máy chủ lưu trữ nó, cùng với dấu vân tay của phần thân yêu cầu và phản hồi mà nó tạo ra.
Tạo khóa mà tác nhân có thể sử dụng lại
Đây là điểm mà hầu hết các triển khai tác nhân thường mắc lỗi. Nếu trình bao công cụ (tool wrapper) tạo một UUID mới trong mỗi lần gọi, khóa sẽ thay đổi trong mỗi lần thử lại, và tính bất biến sẽ không có tác dụng. Khóa phải được gắn với thao tác logic, chứ không phải với lần thử HTTP.
Quy tắc: tạo khóa khi tác nhân quyết định thực hiện một hành động, và giữ khóa đó cho mọi lần thử lại của quyết định đó.
import uuid
class PaymentTool:
def __init__(self, client):
self.client = client
self._keys = {}
def charge(self, task_id, step_id, amount, customer_id):
# One key per (task, step). Retries of the same step reuse it.
op = f"{task_id}:{step_id}"
if op not in self._keys:
self._keys[op] = str(uuid.uuid4())
return self.client.post(
"/v1/payments",
headers={"Idempotency-Key": self._keys[op]},
json={"amount": amount, "customer_id": customer_id},
)
Một khóa xác định cũng hoạt động, và nó tồn tại qua các lần khởi động lại tiến trình, điều mà một từ điển trong bộ nhớ không làm được:
import hashlib
def idempotency_key(task_id: str, step_id: str, payload: dict) -> str:
raw = f"{task_id}|{step_id}|{sorted(payload.items())}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
Rút ra khóa từ lần chạy tác vụ và bước, không bao giờ từ một dấu thời gian hoặc một giá trị ngẫu nhiên được tạo lại mỗi lần thử. Nếu tác nhân khởi động lại toàn bộ tác vụ và thực sự có ý định tạo một giao dịch mới, ID tác vụ sẽ thay đổi và khóa cũng vậy. Đó là hành vi bạn mong muốn.
Những gì máy chủ phải làm
Xử lý tiêu đề một cách chính xác đòi hỏi nhiều hơn là chỉ tra cứu. Một triển khai hoạt động sẽ làm bốn điều sau:
- Khi nhận được, cố gắng yêu cầu khóa. Chèn nó vào một bảng với ràng buộc duy nhất (unique constraint) trước khi thực hiện bất kỳ công việc nào. Nếu việc chèn thất bại, một lần thử khác đã sở hữu nó.
- Nếu khóa tồn tại và dấu vân tay yêu cầu đã lưu khác, từ chối với `422`. Cùng một khóa với phần thân khác nghĩa là có lỗi từ máy khách, và việc âm thầm trả về kết quả cũ sẽ che giấu lỗi đó.
- Nếu khóa tồn tại và lần thử đầu tiên vẫn đang được thực hiện, trả về `409` để bên gọi lùi lại thay vì cạnh tranh.
- Khi công việc hoàn tất, lưu trữ mã trạng thái và phần thân với khóa, sau đó trả về nó cho mọi lần truy cập sau.
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
state TEXT NOT NULL, -- in_progress | completed
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
Đặt thời gian hết hạn. Hai mươi bốn giờ bao gồm bất kỳ khoảng thời gian thử lại thực tế nào, và việc giữ khóa vĩnh viễn biến bảng thành một gánh nặng. Stripe hết hạn khóa sau 24 giờ, đây là một mặc định hợp lý để sao chép.
Kiểm tra rằng lệnh gọi thứ hai không thay đổi gì
Xây dựng tính bất biến là một nửa công việc. Chứng minh nó duy trì là nửa còn lại, và đây là nửa thường bị bỏ qua, bởi vì đường dẫn thành công (happy path) trông giống hệt nhau dù tính năng có hoạt động hay không.
Việc kiểm thử rất đơn giản để mô tả: gửi yêu cầu, ghi lại kết quả, gửi lại yêu cầu giống hệt, và khẳng định rằng máy chủ không thực hiện công việc hai lần. Phần khó là khẳng định cuối cùng, vì bản thân phản hồi sẽ không cho bạn biết. Hai giao dịch thành công đều trả về `200`.
Vì vậy, khẳng định dựa trên trạng thái, không phải trên phản hồi:
- Phần thân phản hồi thứ hai khớp với phần đầu tiên, bao gồm ID tài nguyên. Một ID mới có nghĩa là một tài nguyên mới đã được tạo.
- Một lệnh `GET` tiếp theo trên tập hợp trả về một bản ghi, không phải hai.
- Bất kỳ bộ đếm hoặc số dư nào chỉ thay đổi một lần.
Trong Apidog, bạn có thể thiết lập điều này như một kịch bản kiểm thử: bước một gửi yêu cầu `POST` với một `Idempotency-Key` cố định, bước hai lặp lại yêu cầu đó, và bước ba liệt kê tài nguyên và khẳng định số lượng. Lưu ID phản hồi từ bước một vào một biến và khẳng định bước hai trả về cùng một giá trị. Bởi vì toàn bộ kịch bản được lưu trữ, nó chạy trong CI mỗi khi có thay đổi đối với đường dẫn thanh toán, nơi các lỗi hồi quy thực sự xuất hiện. Kỹ thuật tương tự cũng áp dụng cho các mô hình rộng hơn trong hướng dẫn kiểm thử hợp đồng API của chúng tôi.

Hai trường hợp nữa đáng được đề cập, vì chúng giúp phát hiện các lỗi thực tế:
- Cùng một khóa, phần thân khác. Mong đợi `422`, không phải một thành công âm thầm.
- Các bản sao đồng thời. Gửi cả hai yêu cầu cùng lúc và xác nhận chính xác một yêu cầu thắng. Điều này phát hiện ra ràng buộc duy nhất bị thiếu mà một kiểm thử tuần tự sẽ không bao giờ phát hiện được.
Việc tạo mock cũng hữu ích ở đây. Nếu bạn vẫn đang xây dựng tác nhân và API thanh toán chưa tồn tại, hãy tạo mock cho nó với một phản hồi nhận biết tính bất biến để logic thử lại của tác nhân được thực hành sớm. Bài viết của chúng tôi về lý do các tác nhân nên sử dụng mock API thay vì môi trường sản phẩm đưa ra lý do rộng hơn cho thói quen đó.
Khi bạn không thể thêm khóa
Đôi khi API không phải của bạn và nó không hỗ trợ tính bất biến. Bạn vẫn có các lựa chọn, theo thứ tự ưu tiên tương đối.
- Làm cho thao tác có tính bất biến một cách tự nhiên. Một lệnh `PUT` đến đường dẫn tài nguyên mà máy khách chọn là bất biến theo thiết kế: `PUT /orders/{client_order_id}`. Nếu bạn kiểm soát thiết kế API, hãy ưu tiên điều này hơn là `POST` cộng với một tiêu đề. Nó không cần thêm bảng nào.
- Kiểm tra trước khi ghi. Yêu cầu tác nhân truy vấn một bản ghi hiện có với cùng khóa tự nhiên trước khi nó tạo một bản ghi mới. Điều này yếu hơn, vì một cuộc chạy đua giữa việc kiểm tra và việc ghi vẫn có thể tạo ra hai bản ghi, nhưng nó loại bỏ trường hợp hết thời gian chờ phổ biến.
- Loại bỏ trùng lặp ở phía hạ lưu. Nếu thao tác ghi là một tin nhắn hoặc một sự kiện, hãy đặt việc loại bỏ trùng lặp vào bên tiêu thụ. Gắn một ID tin nhắn ổn định và yêu cầu bên tiêu thụ loại bỏ các bản lặp lại. Đây là một thực hành tiêu chuẩn trong các hệ thống hướng sự kiện và kết hợp với hướng dẫn trong hướng dẫn webhook đáng tin cậy của chúng tôi.
- Kiểm soát hành động. Đối với các thao tác thực sự không thể đảo ngược và không thể biến thành bất biến, hãy đặt một người ở phía trước. Đó là mô hình cổng phê duyệt từ bài viết của chúng tôi về hàng rào bảo vệ tác nhân AI, và đó là câu trả lời đúng khi chi phí của việc trùng lặp đủ cao.
Biết lần chạy nào đã làm gì
Tính bất biến ngăn chặn việc trùng lặp. Nó không cho bạn biết lần thử nào đã tạo ra bản ghi, và đó là câu hỏi bạn sẽ được hỏi sau một sự cố.
Giữ nhận dạng lần chạy gắn liền với công việc. Khi tác nhân là dịch vụ của riêng bạn, điều đó có nghĩa là ID tác vụ và ID bước từ việc tạo khóa ở trên, được ghi lại với mỗi lần thử. Khi tác nhân là một môi trường chạy mã (coding runtime) thực thi công việc được giao, nền tảng thường giữ nó cho bạn: trong Sharkly, mỗi lần chạy được gắn với Tác vụ mà nó xuất phát, với trạng thái thực thi và kết quả của nó được lưu trữ cùng với luồng bình luận, do đó một thao tác ghi lặp lại có thể truy ngược về một lần chạy cụ thể chứ không phải một lần thử lại ẩn danh.

Danh sách kiểm tra trước khi triển khai
- Mọi công cụ không bất biến mà tác nhân có thể gọi đều yêu cầu một khóa bất biến, và trình bao công cụ sẽ từ chối gửi nếu không có khóa đó.
- Khóa được rút ra từ tác vụ và bước, không phải từ lần thử.
- Máy chủ yêu cầu khóa trước khi thực hiện công việc, không phải sau đó.
- Cùng một khóa với tải trọng (payload) khác trả về lỗi thay vì phản hồi đã lưu vào bộ nhớ cache.
- Các bản sao đồng thời được xử lý bằng ràng buộc cơ sở dữ liệu, không phải bằng thời gian ứng dụng.
- Một bài kiểm thử đã lưu chứng minh rằng lệnh gọi thứ hai không thay đổi gì, và nó chạy trong CI.
- Khóa hết hạn theo lịch trình và bảng được dọn dẹp.
Làm việc theo danh sách đó và câu chuyện về việc tính phí hai lần sẽ không còn khả thi, điều đó có nghĩa là chính sách thử lại của bạn có thể trở nên mạnh mẽ hơn chứ không phải yếu đi. Đó là lợi ích thực sự: tính bất biến là điều cho phép bạn làm cho một tác nhân trở nên linh hoạt mà không làm cho nó nguy hiểm.
Các câu hỏi thường gặp
- Tôi có cần khóa bất biến cho các công cụ chỉ đọc không? Không. Các yêu cầu `GET` đã là bất biến và an toàn, vì vậy việc thử lại một yêu cầu chỉ tốn một chút độ trễ chứ không có gì khác. Dành khóa cho các lệnh gọi tạo, tính phí, gửi hoặc thay đổi trạng thái.
- Khóa nên được tạo ở đâu, trong tác nhân hay trong trình bao công cụ? Trong trình bao công cụ, dựa trên các định danh tác vụ và bước của tác nhân. Để mô hình tạo khóa là một sai lầm: các mô hình tạo lại giá trị khi thử lại và có thể tạo ra xung đột giữa các tác vụ.
- Một yêu cầu lặp lại nên trả về mã trạng thái nào? Trả về trạng thái đã lưu từ lệnh gọi gốc, vì vậy một lệnh `POST` thứ hai mà ban đầu trả về `201` sẽ trả về `201` một lần nữa với cùng phần thân. Một số API thêm một tiêu đề như `Idempotent-Replay: true` để đánh dấu lần lặp lại, điều này hữu ích cho việc gỡ lỗi và vô hại đối với các máy khách bỏ qua nó.
- Nên giữ khóa trong bao lâu? Hai mươi bốn giờ bao gồm gần như mọi khoảng thời gian thử lại. Việc lưu giữ lâu hơn hiếm khi hữu ích và làm tăng kích thước bảng không giới hạn. Nếu một máy khách thử lại sau khoảng thời gian đó, hãy coi đó là một thao tác mới.
- Điều này có thay thế các giao dịch không? Không. Khóa bất biến ngăn các yêu cầu trùng lặp tạo ra các hiệu ứng trùng lặp. Các giao dịch giữ cho một yêu cầu duy nhất là nguyên tử (atomic). Bạn cần cả hai, và việc yêu cầu khóa nên được ghi trong cùng một giao dịch với công việc bất cứ khi nào cơ sở dữ liệu của bạn cho phép.
- Làm thế nào để tôi kiểm thử điều này mà không có nhà cung cấp thanh toán thực sự? Hướng tác nhân đến một mock triển khai ngữ nghĩa khóa, bao gồm cả `422` khi tải trọng không khớp. Hướng dẫn của chúng tôi về kiểm thử tác nhân AI với API giả lập (mocked API) bao gồm phần thiết lập, và Tải xuống Apidog nếu bạn muốn mock và kiểm thử thử lại nằm trong cùng một dự án.
