Mình từng xử lý một incident mà nguyên nhân gốc rễ không phải payment service down — mà là service gọi nó không có timeout.

Payment service bắt đầu chậm lúc 2h chiều, response time tăng dần từ 200ms lên 28s rồi treo hẳn. Service gọi nó vẫn tiếp tục gọi, thread pool bắt đầu lấp đầy vì mỗi request đang block chờ response không bao giờ đến. Sau 4 phút, thread pool exhausted, toàn bộ request mới bắt đầu queue up. Sau 7 phút, memory tăng vọt. Sau 12 phút, service gọi payment cũng chết theo — dù nó không có bug gì cả. Cascade failure kinh điển. Fix chỉ cần một dòng: set timeout.

Ba lớp bảo vệ — timeout, retry, backoff — thường bị implement thiếu một, và chính lớp thiếu đó là điểm fail. Bài này đi qua cả ba, bao gồm phần mà docs thường bỏ qua.


Connect timeout, read timeout, write timeout — ba cái khác nhau

Hầu hết developer khi được hỏi “đã set timeout chưa?” đều trả lời “rồi” — nhưng hỏi kỹ hơn thì chỉ set một giá trị chung. Vấn đề là có ba loại timeout khác nhau, xảy ra ở ba thời điểm khác nhau trong vòng đời một HTTP request.

Connect timeout là thời gian tối đa để thiết lập TCP connection đến server. Nếu server unreachable (network partition, port closed, firewall drop packet), connect timeout sẽ trigger. Giá trị hợp lý: 1–3s. Connection trong cùng datacenter thường mất dưới 5ms — nếu sau 1s vẫn chưa connect được thì có vấn đề thật sự rồi.

Read timeout là thời gian tối đa chờ server bắt đầu trả về response sau khi request đã được gửi đi. Đây là loại timeout quan trọng nhất và thường bị set quá cao. Payment service timeout sau 30s ở ví dụ trên là read timeout — connection đã thiết lập thành công, request đã gửi đi, nhưng server không trả về gì cả.

Write timeout là thời gian tối đa để gửi xong request body đến server. Ít gặp hơn, nhưng cần thiết khi upload file lớn hoặc gửi payload phức tạp. Nếu network giữa hai service chậm bất thường, write timeout sẽ bắt được vấn đề trước khi request thậm chí chưa được server xử lý.

// got — HTTP client hỗ trợ tách ba loại timeout riêng biệt
import got from "got";

const paymentClient = got.extend({
  prefixUrl: "https://payment-service.internal",
  timeout: {
    connect: 1500, // 1.5s để thiết lập TCP connection
    send: 3000, // 3s để gửi xong request body
    response: 5000, // 5s chờ server bắt đầu trả response
  },
  // axios chỉ có một `timeout` cho toàn bộ request — ít kiểm soát hơn
});
Lưu ý: Axios gộp connect + read vào một timeout duy nhất. Nếu cần phân tách, dùng got, undici, hoặc node-fetch với AbortController. Tách riêng giúp alert có ý nghĩa hơn: connect timeout thường là network issue, read timeout thường là processing issue của downstream.

Retry chỉ an toàn với idempotent operations

Hiểu nôm na thì idempotent nghĩa là: gọi 1 lần hay 10 lần, kết quả cuối cùng đều như nhau. GET /users/123 trả về user, gọi 10 lần vẫn trả về user đó — idempotent. DELETE /orders/456 xoá order, gọi lần hai vẫn chỉ “order đã bị xoá” — idempotent.

POST /payments tạo một thanh toán mới — không idempotent. Gọi 2 lần = khách bị trừ tiền 2 lần. Đây là lỗi mình thấy trong production nhiều hơn tưởng — developer thêm retry vào payment call vì “đôi khi có network blip”, và tạo ra hàng trăm giao dịch trùng lặp mỗi tháng mà không ai biết.

Vậy nếu payment service không reliable, phải làm gì? Idempotency key — client generate một UUID duy nhất cho mỗi intent thanh toán, gửi kèm trong header. Server dùng key đó để deduplicate: nếu đã thấy key này rồi, trả về kết quả cũ thay vì tạo mới.

import { v4 as uuidv4 } from "uuid";

// Tạo idempotency key gắn với intent — KHÔNG phải với request
async function createPayment(orderId: string, amount: number) {
  // Key này được lưu DB trước khi gọi API, persist qua các lần retry
  const idempotencyKey = `pay-${orderId}-${uuidv4().replace(/-/g, "").slice(0, 12)}`;

  return await retryWithBackoff(async () => {
    return await paymentClient
      .post("payments", {
        json: { orderId, amount },
        headers: {
          // Key giống nhau mỗi lần retry — server tự deduplicate
          "Idempotency-Key": idempotencyKey,
        },
      })
      .json();
  });
}

Quy tắc đơn giản trước khi thêm retry: tự hỏi “nếu call này thành công nhưng response bị mất trước khi mình nhận được thì sao?” Nếu câu trả lời là “tệ” — cần idempotency key trước khi retry.


Exponential backoff + jitter — tại sao cần cả hai

Exponential backoff là: sau mỗi lần fail, chờ lâu hơn trước khi thử lại. Thử lần 1 thất bại → chờ 200ms. Lần 2 thất bại → chờ 400ms. Lần 3 → chờ 800ms. Ý tưởng là cho downstream service thời gian recover.

Nhưng backoff thuần túy có vấn đề: nếu 500 client cùng fail cùng lúc do server restart, tất cả sẽ retry cùng lúc sau đúng 200ms, rồi đồng loạt lại sau 400ms. Đây là thundering herd — mỗi wave retry đập server vừa mới recover, làm nó fail lại, tạo wave tiếp theo. Mình đã thấy hệ thống dao động 38 phút chỉ vì thiếu jitter.

Jitter thêm randomness vào thời gian chờ để các client không đồng loạt retry cùng thời điểm. Công thức chuẩn: min(cap, base * 2^attempt) * random(0.5, 1.5).

interface RetryConfig {
  maxAttempts: number;
  baseDelayMs: number; // Delay cơ sở (ms)
  maxDelayMs: number; // Cap tối đa
  timeoutMs: number; // Timeout cho mỗi attempt
}

// min(cap, base * 2^attempt) * random(0.5, 1.5)
function calculateBackoffDelay(attempt: number, config: RetryConfig): number {
  const exponential = config.baseDelayMs * Math.pow(2, attempt);
  const capped = Math.min(config.maxDelayMs, exponential);
  // Full jitter: random trong khoảng [0.5x, 1.5x]
  return Math.floor(capped * (0.5 + Math.random()));
}

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  config: RetryConfig = {
    maxAttempts: 3,
    baseDelayMs: 200,
    maxDelayMs: 10_000,
    timeoutMs: 5_000,
  }
): Promise<T> {
  let lastError: Error;

  for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
    try {
      // Mỗi attempt có timeout riêng — không để một attempt chậm chiếm hết budget
      const result = await Promise.race([
        fn(),
        new Promise<never>((_, reject) =>
          setTimeout(
            () => reject(new Error(`Timeout after ${config.timeoutMs}ms`)),
            config.timeoutMs
          )
        ),
      ]);
      return result;
    } catch (error) {
      lastError = error as Error;

      // Không retry 4xx (trừ 429) — lỗi client, retry cũng vô nghĩa
      if (isNonRetryableError(error)) {
        throw error;
      }

      if (attempt < config.maxAttempts - 1) {
        const delayMs = calculateBackoffDelay(attempt, config);
        await new Promise((resolve) => setTimeout(resolve, delayMs));
      }
    }
  }

  throw lastError!;
}

function isNonRetryableError(error: unknown): boolean {
  if (error instanceof Error && "response" in error) {
    const status = (error as any).response?.statusCode as number;
    return status >= 400 && status < 500 && status !== 429;
  }
  return false;
}

Với config trên: attempt 0 chờ 100–300ms, attempt 1 chờ 200–600ms, attempt 2 chờ 400–1200ms. Các client trải đều retry trong window đó thay vì pile up cùng lúc.


Max retry budget — giới hạn mà ít người nghĩ đến

Thử tưởng tượng: Service A có 3 retry, gọi Service B cũng có 3 retry, Service B gọi Service C cũng có 3 retry. Nếu request gốc fail, tổng số request thực sự gửi đi là 3 × 3 × 3 = 27 requests cho 1 request ban đầu. Đây là retry amplification — và nó không tuyến tính.

Retry budget giới hạn tổng: đảm bảo số request retry không vượt quá X% tổng traffic. Google SRE Book đề xuất 10% — nếu hơn 10% request đang là retry, đó là dấu hiệu vấn đề hệ thống, không phải transient error nữa.

class RetryBudget {
  private retryCount = 0;
  private totalCount = 0;

  constructor(
    private readonly maxRetryRatio = 0.1, // 10% tối đa
    windowMs = 60_000
  ) {
    // Reset counter theo sliding window đơn giản
    setInterval(() => {
      this.retryCount = 0;
      this.totalCount = 0;
    }, windowMs);
  }

  canRetry(): boolean {
    if (this.totalCount < 10) return true; // Ổn định trước khi enforce
    return this.retryCount / this.totalCount < this.maxRetryRatio;
  }

  recordRequest(): void {
    this.totalCount++;
  }
  recordRetry(): void {
    this.retryCount++;
  }

  get retryRatio(): number {
    return this.totalCount === 0 ? 0 : this.retryCount / this.totalCount;
  }
}

Một gotcha thực tế: mình từng thấy hệ thống có retry budget nhưng chỉ track ở sender. Service A retry 9% (dưới budget), Service B retry 9% (dưới budget), nhưng Service C — đích của cả hai — nhận 18% retry traffic và bị overwhelm. Budget cần track từ góc nhìn của receiver, không phải sender.


Kết hợp cả ba lớp

Ba lớp hoạt động tốt nhất khi phối hợp nhau:

Request đến
[Timeout] — connect: 1.5s, send: 3s, read: 5s
    ↓ timeout?
[Retry Budget] — < 10% traffic đang retry?
    ↓ budget còn?
[Backoff + Jitter] — 200ms * 2^attempt * random(0.5, 1.5), cap 10s
    ↓ hết maxAttempts?
Fail fast với lỗi rõ ràng cho caller

Thiếu timeout → thread pool exhausted khi downstream chậm. Thiếu idempotency check → duplicate transaction. Thiếu jitter → thundering herd khi cluster recover. Ba lớp, ba failure mode khác nhau, ba dòng config để fix.

Điều ironique là cả ba chỉ mất vài chục dòng code — nhưng thường chỉ được thêm vào sau incident đầu tiên, không phải trước.