Viết lớp gọi API cho agent TypeScript: timeout, retry và idempotency key
Agent của bạn gọi lại một API bị treo, và hệ thống khách hàng hoàn tiền hai lần; khoảng 80 dòng TypeScript là đủ để chuyện đó không xảy ra.
- 1Sinh idempotency keyChỉ cho POST, một UUID V4 cho mỗi thao tác nghiệp vụ, đặt ngoài vòng lặp
- 2Gọi với timeoutAbortSignal.timeout() theo p99.9 độ trễ của API phía dưới
- 3Phân loại kết quảRetry 408, 409, 429 và lỗi mạng; 5xx chỉ retry khi request không mang key
- 4Kiểm tra ngân sáchToken bucket hết token hoặc đã quá số lần thử thì dừng và báo lỗi
- 5Chờ backoff có jitterThời gian chờ ngẫu nhiên rồi gửi lại với đúng key cũ
Key sinh một lần trước vòng lặp, nên mọi lần retry đều trỏ về cùng một thao tác.
Đồ hoạ: FDE Times
Tóm tắt nhanh
- API có side effect chỉ retry an toàn được khi chúng hỗ trợ idempotency.
- Idempotency key sinh một lần cho mỗi thao tác nghiệp vụ, dùng lại qua mọi lần retry, chỉ gắn vào POST.
- Retry phải có ngân sách: nếu mỗi tầng tự retry, tải dồn xuống database có thể tăng tới 243 lần.
Thử hình dung tuần thứ hai bạn ở chỗ khách hàng. Agent hỗ trợ khách hàng mà team bạn deploy có một tool tên createRefund, gọi API hoàn tiền nội bộ của họ. Một buổi chiều mạng chập chờn, request đầu tiên không có phản hồi, agent thử lại, và sáng hôm sau đội tài chính phát hiện một đơn được hoàn tiền hai lần.
Lỗi này không nằm ở model. Nó nằm ở hơn chục dòng code bọc fetch mà ai cũng nghĩ là việc vặt. Với một FDE, lớp gọi API chính là chỗ agent chạm vào tiền, tồn kho và dữ liệu thật của khách hàng, nên đây là đoạn code bạn phải viết tốt ngay từ lần đầu.
Lớp này cần ba thứ: timeout, retry có kỷ luật và idempotency key. Phần dưới đi qua từng thứ, rồi gom lại thành một hàm TypeScript hoàn chỉnh để bạn chép về và sửa cho dự án của mình.
Vì sao retry một cách ngây thơ lại nguy hiểm?
Amazon Builders’ Library nói thẳng: API có side effect không an toàn để retry, trừ khi nó hỗ trợ idempotency. Khi request hoàn tiền bị timeout, client không biết server đã xử lý hay chưa. Có thể gói tin chưa tới nơi, cũng có thể tiền đã chuyển xong và chỉ có phản hồi bị lạc trên đường về.
Idempotency key giải quyết đúng chỗ mơ hồ đó. Tài liệu của Stripe mô tả rằng khi có lỗi kết nối, bạn có thể gửi lại request mà không sợ tạo ra object thứ hai hay cập nhật hai lần.
Cơ chế cũng đơn giản: Stripe lưu status code và body của request đầu tiên ứng với mỗi key, dù request đó thành công hay thất bại, rồi trả lại đúng kết quả ấy cho mọi lần gửi lại.
Chi tiết “dù thất bại” rất đáng chú ý. Theo cách Stripe mô tả, nếu lần đầu trả về lỗi 500 thì retry với cùng key sẽ nhận lại đúng lỗi 500 đó. Vậy retry với key cũ chủ yếu có ích khi lỗi xảy ra ở đường truyền, tức timeout hay mất kết nối, lúc client chưa nhận được kết quả nào.
Timeout bao lâu thì vừa?
Giá trị mặc định thường quá dài so với nhu cầu của agent. SDK Node của OpenAI để timeout mặc định 10 phút, hợp lý cho một lời gọi model dài, nhưng một API tra cứu đơn hàng thì không nên được chờ lâu như vậy.
Amazon đưa ra một cách chọn có cơ sở. Trước hết quyết định bạn chấp nhận bao nhiêu phần trăm timeout “oan”, tức những request lẽ ra vẫn thành công nếu được chờ thêm, rồi lấy percentile độ trễ tương ứng của service phía dưới, ví dụ p99.9.
Giả sử p99.9 của API hoàn tiền là 1,2 giây thì timeout khoảng 1,2 giây nghĩa là bạn chấp nhận cắt oan cỡ 0,1% request.
Trong Node, bạn không cần tự dựng setTimeout. AbortSignal.timeout() trả về một signal tự abort sau thời gian cho trước, và khi hết giờ nó abort bằng một DOMException tên TimeoutError. Nhờ vậy code phân biệt được timeout với trường hợp người dùng chủ động huỷ, mà hai trường hợp này cần xử lý khác nhau.
Retry bao nhiêu lần là đủ?
SDK của OpenAI là một thiết kế tham khảo tốt. Theo README, nó tự retry 2 lần với exponential backoff ngắn, và mặc định retry các lỗi 408, 409, 429, mọi lỗi từ 500 trở lên, cùng lỗi kết nối. Lỗi 400 hay 401 thì không, vì gửi lại y nguyên cũng chẳng thay đổi được gì.
Backoff thôi chưa đủ, cần thêm jitter. Amazon giải thích jitter là thêm một chút ngẫu nhiên vào thời gian chờ để các lần retry rải ra theo thời gian. Không có jitter, một nghìn phiên agent cùng lỗi một lúc sẽ cùng retry vào đúng một khoảnh khắc và đánh sập service thêm lần nữa.
Nguy hiểm lớn nhất là retry chồng tầng. Amazon nêu ví dụ một hệ thống 5 tầng, mỗi tầng tự retry độc lập, khiến tải xuống database tăng 243 lần.
Tính ngược lại, 243 chính là 3 mũ 5, tương ứng với việc mỗi tầng thử tối đa 3 lần. Cách Amazon dùng để chặn là giới hạn retry ngay tại chỗ bằng token bucket: hết token thì thôi retry và trả lỗi lên trên.
Bản hoàn chỉnh trong TypeScript
Dưới đây là hàm callApi gom đủ ba thứ trên. Hãy để ý dòng sinh key: nó nằm ngoài vòng lặp.
import { randomUUID } from "node:crypto";
type Method = "GET" | "POST" | "DELETE";
interface CallOptions {
method: Method;
url: string;
body?: unknown;
timeoutMs: number; // lấy từ p99.9 của API phía dưới
maxRetries?: number; // mặc định 2
idempotencyKey?: string; // truyền vào nếu thao tác đã có key từ trước
signal?: AbortSignal; // signal huỷ từ phía agent hoặc người dùng
}
// Token bucket: tối đa 10 lần retry, hồi 1 token mỗi giây
class RetryBudget {
private tokens = 10;
constructor() {
setInterval(() => (this.tokens = Math.min(10, this.tokens + 1)), 1000).unref();
}
take(): boolean {
if (this.tokens <= 0) return false;
this.tokens--;
return true;
}
}
const budget = new RetryBudget();
// Request mang key: không retry 5xx, vì server đã lưu và sẽ trả lại đúng lỗi cũ
const isRetryableStatus = (s: number, hasKey: boolean) =>
s === 408 || s === 409 || s === 429 || (!hasKey && s >= 500);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const backoff = (attempt: number) =>
Math.random() * Math.min(5000, 200 * 2 ** attempt); // jitter
export async function callApi(opts: CallOptions): Promise<Response> {
const maxRetries = opts.maxRetries ?? 2;
// Sinh MỘT lần cho cả thao tác; chỉ POST mới mang key
const key =
opts.method === "POST" ? opts.idempotencyKey ?? randomUUID() : undefined;
for (let attempt = 0; ; attempt++) {
// Mỗi lần gọi có timeout riêng, gộp với signal huỷ của người gọi
const timeout = AbortSignal.timeout(opts.timeoutMs);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
try {
const res = await fetch(opts.url, {
method: opts.method,
headers: {
"Content-Type": "application/json",
...(key ? { "Idempotency-Key": key } : {}), // tên header tuỳ API
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
signal,
});
const canRetry =
isRetryableStatus(res.status, key !== undefined) &&
attempt < maxRetries &&
budget.take();
if (!canRetry) return res;
await res.body?.cancel(); // bỏ body để giải phóng kết nối trước khi thử lại
} catch (err) {
// Người gọi chủ động huỷ thì dừng ngay, không retry
if (opts.signal?.aborted || attempt >= maxRetries || !budget.take()) throw err;
// Còn lại là TimeoutError hoặc lỗi mạng: gửi lại với CÙNG key
}
await sleep(backoff(attempt));
if (opts.signal?.aborted) throw opts.signal.reason;
}
}
Quay lại tình huống hoàn tiền. Lần gọi đầu bị timeout, catch nhận TimeoutError, hàm chờ một khoảng ngẫu nhiên rồi gửi lại với đúng key cũ. Nếu server đã xử lý lần trước, nó trả lại kết quả đã lưu và không có lần hoàn tiền thứ hai.
Hai chi tiết nhỏ đáng giữ khi bạn sửa hàm này. Signal huỷ của người gọi được gộp với timeout bằng AbortSignal.any(), nên khi agent bị dừng giữa chừng, hàm thoát ngay thay vì retry tiếp. Còn response bị bỏ qua để retry thì được huỷ body, để kết nối không bị treo lại trong pool.
Điều kiện retry ở đây hẹp hơn SDK của OpenAI một chỗ: request mang key không retry lỗi từ 500 trở lên. Lý do nằm ở cơ chế đã nói. Với một API lưu kết quả đầu tiên theo key như Stripe, gửi lại cùng key sau lỗi 500 chỉ nhận về đúng lỗi 500 cũ, tốn ngân sách mà không được gì.
GET và DELETE không mang key nên vẫn retry 5xx như thường. Nếu API của khách hàng xử lý key theo cách khác, hãy hỏi họ trước rồi mới nới điều kiện này.
Mã 409 thì vẫn đáng retry khi đó là xung đột với một request khác đang chạy song song. Stripe ghi rằng họ không lưu kết quả khi tham số không qua validation hoặc khi request xung đột với một request đang chạy, nên trong những trường hợp đó gửi lại vẫn an toàn.
Những lỗi hay gặp ở hiện trường
Lỗi phổ biến nhất là sinh key bên trong vòng lặp. Mỗi lần retry lại mang một key mới, server coi đó là một thao tác mới, và idempotency coi như không tồn tại.
Với agent còn có một biến thể khó thấy hơn: model gọi lại cùng một tool ở bước sau, và lớp của bạn sinh key mới cho lần gọi đó. Nếu orchestrator có thể chạy lại một bước, hãy sinh key từ ID của bước đó rồi truyền vào qua idempotencyKey.
Lỗi thứ hai là dùng lại key nhưng đổi tham số. Stripe so tham số mới với request gốc và báo lỗi nếu chúng khác nhau. Họ cũng có thể xoá key sau 24 giờ, nên một job chạy lại ngày hôm sau không thể trông vào key cũ.
Lỗi thứ ba là gắn key vào mọi request. Stripe nói rõ GET và DELETE vốn đã idempotent, gửi key theo cũng không có tác dụng gì. Cũng đừng nhét email hay số tài khoản vào key: Stripe khuyên dùng UUID V4 hoặc một chuỗi ngẫu nhiên đủ entropy, dài tối đa 255 ký tự, và không chứa dữ liệu nhạy cảm.
Lỗi cuối cùng, cũng là lỗi khó thấy nhất, là retry chồng lên SDK. Nếu tool của bạn bọc một client vốn đã tự retry 2 lần, rồi orchestrator của agent lại retry thêm, hệ số khuếch đại đã nhân lên trước khi bạn kịp nhận ra. Hãy chọn đúng một tầng được retry, và tắt retry ở những tầng còn lại.
Đưa kỹ năng này vào CV
Khi đọc JD của một vị trí FDE, hãy tìm những yêu cầu về tích hợp với hệ thống của khách hàng hay độ tin cậy khi chạy production: đó là chỗ để bạn kể về loại code này.
Trong CV, đừng chỉ viết “xây agent gọi API”. Hãy ghi bạn đặt timeout dựa trên percentile nào, retry ở tầng nào, và dùng idempotency để chặn thao tác trùng ra sao.
Ở buổi phỏng vấn, câu hỏi “nếu request tạo đơn bị timeout thì agent của bạn làm gì?” là cơ hội để bạn vẽ lại vòng lặp trên. Người phỏng vấn sẽ nhớ ứng viên biết rằng timeout không có nghĩa là thất bại, mà là chưa biết kết quả.