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.