Client chờ 30s. API gateway 30s. Service A gọi B 30s. B gọi C 30s. C chậm 25s → A/B vẫn “ok trong hạn” nhưng user đã bỏ app từ lâu; hoặc ngược lại: mỗi tầng retry 3 lần × 10s timeout → một request logic kéo cả phút và giữ connection/pool.
Timeout budget (deadline propagation): cả chuỗi chia một hạn tổng. Còn 200ms thì đừng bắt đầu call 2s; cancel work đang chạy khi deadline qua.
Demo: Budget vs timeout cố định mỗi hop.
Timeout cố định mỗi tầng — sai ở đâu
User ──30s──► API ──30s──► A ──30s──► B
└── retry ×3 (mỗi lần full 30s?)
- Thời gian tường thuật có thể vượt xa SLA user.
- Tầng trong vẫn làm việc sau khi client đã disconnect — đốt CPU/DB vô ích (cần abort).
- Tail latency: vài hop “hơi chậm” cộng dồn thành p99 nổ.
Deadline chung
// Ý: deadline = thời điểm tuyệt đối, không phải “timeout lẻ” mỗi lần
const deadline = Date.now() + 2000; // 2s cho cả request
async function callB(deadline) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error("deadline_exceeded");
return fetch(url, {
signal: AbortSignal.timeout(remaining), // hoặc AbortSignal.any([...])
});
}
Truyền deadline (hoặc AbortSignal cha)
xuống mọi I/O. Thư viện gRPC có deadline; HTTP hay dùng header
nội bộ / context (Go context.WithDeadline, JS
AbortSignal).
Sketch: budget wrapper
function createBudget(totalMs) {
const ac = new AbortController();
const deadline = Date.now() + totalMs;
const timer = setTimeout(() => ac.abort(new Error("budget_exhausted")), totalMs);
return {
signal: ac.signal,
remaining() {
return Math.max(0, deadline - Date.now());
},
async run(fn) {
if (ac.signal.aborted) throw ac.signal.reason;
const left = this.remaining();
if (left <= 0) {
ac.abort();
throw new Error("budget_exhausted");
}
try {
return await fn({ signal: ac.signal, remaining: left, deadline });
} finally {
/* caller có thể clear timer khi request xong */
}
},
dispose() {
clearTimeout(timer);
},
};
}
// API handler
const budget = createBudget(2000);
try {
const a = await budget.run(({ signal, remaining }) =>
callServiceA({ signal, timeout: remaining })
);
const b = await budget.run(({ signal, remaining }) =>
callServiceB({ signal, timeout: remaining }) // remaining đã giảm
);
return { a, b };
} finally {
budget.dispose();
}
Retry trong budget
Backoff chỉ hợp lệ
khi remaining > next_delay + min_useful_work. Hết
budget → fail ngay, không sleep thêm 2s chỉ để timeout.
if (budget.remaining() < delay + 50) throw lastErr; // đừng retry
await sleep(delay, { signal: budget.signal });
await attempt({ signal: budget.signal });
Hedge / fan-out
Gọi 2 replica, lấy ai nhanh hơn: dễ tăng load. Chỉ khi p99 đau và budget cho phép; cancel nhánh thua (AbortSignal) — không để cả hai chạy hết.
Bẫy thực tế
- Timeout client < tổng thời gian server “hợp lệ” → user lỗi, server vẫn commit (cần idempotency + thiết kế async).
- Không abort DB query / upstream khi client cancel — work ma.
-
Log “timeout” không ghi
remaining/ hop nào hết trước → debug mù.
Một câu để nhớ
Một deadline cho cả câu chuyện request — mỗi hop chỉ được tiêu phần thời gian còn lại, và phải biết hủy khi hết. Timeout copy-paste 30s mỗi tầng là cách nuôi p99 và zombie work.