Retry và Circuit Breaker: xử lý lỗi tạm thời trong hệ thống phân tán — hoatq.dev

cat blog/.md

Retry và Circuit Breaker: xử lý lỗi tạm thời trong hệ thống phân tán

date: tags: backend, microservices, resilience, design-pattern

Service A gọi Service B. Service B đang deploy lại, mất khoảng 10 giây. Trong 10 giây đó, Service A nhận liên tục Connection refused. Nếu không xử lý gì, toàn bộ request đến Service A cũng fail theo — dù logic phía A hoàn toàn không có vấn đề.

Tệ hơn: 50 instance của Service A đồng loạt retry vào Service B vừa lúc nó khởi động xong. Service B chưa kịp warm up đã bị đập 5.000 requests/giây. Lại chết.

Đây là cascading failure — lỗi lan từ service này sang service khác như hiệu ứng domino. Và hai pattern cơ bản nhất để chống lại nó là retrycircuit breaker.

Retry: thử lại, nhưng phải thông minh

Ý tưởng đơn giản: request fail → đợi một chút → thử lại. Nhiều lỗi trong hệ thống phân tán là tạm thời (transient) — network blip, service đang restart, database connection pool đầy trong tích tắc. Thử lại là đủ.

Nhưng retry ngây thơ sẽ giết hệ thống.

Đừng retry ngay lập tức

# ❌ Sai: retry ngay, không delay
for i in range(3):
    try:
        response = await call_service_b()
        break
    except ConnectionError:
        continue  # retry ngay → tạo spike traffic

Service B đang quá tải mà bạn gửi thêm request liên tục? Nó càng không thể recover.

Exponential backoff + jitter

Pattern chuẩn: mỗi lần retry, tăng delay theo cấp số nhânthêm random jitter để tránh nhiều client retry đồng thời.

import asyncio
import random
import httpx

async def call_with_retry(
    url: str,
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
):
    for attempt in range(max_retries + 1):
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                response = await client.get(url)
                response.raise_for_status()
                return response.json()
        except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:
            if attempt == max_retries:
                raise

            # Exponential backoff: 1s, 2s, 4s, ...
            delay = min(base_delay * (2 ** attempt), max_delay)
            # Full jitter: random từ 0 đến delay
            jitter = random.uniform(0, delay)

            print(f"Attempt {attempt + 1} failed: {e}. Retrying in {jitter:.1f}s...")
            await asyncio.sleep(jitter)

Tại sao jitter quan trọng? Hình dung 100 client đều fail cùng lúc. Không có jitter, tất cả đều retry sau đúng 1 giây, rồi 2 giây, rồi 4 giây — tạo ra thundering herd. Jitter phân tán các retry ra khắp timeline, giảm đáng kể spike.

AWS SDK dùng pattern này mặc định và gọi nó là “full jitter” — đã được chứng minh hiệu quả trong production scale lớn.

Chỉ retry lỗi tạm thời

Không phải lỗi nào cũng nên retry:

Status codeNên retry?Lý do
500, 502, 503✅ CóServer error, có thể tạm thời
429✅ CóRate limited, đợi rồi thử lại
408✅ CóTimeout, có thể do network
400, 401, 403, 404❌ KhôngClient error, retry cũng vậy
409⚠️ TùyConflict, cần check idempotency

Retry một request 400 Bad Request 3 lần không thay đổi kết quả — chỉ tốn resource.

Idempotency: retry an toàn không?

Câu hỏi quan trọng nhất trước khi retry: gọi lại có tạo side effect không?

  • GET /orders/123 → retry thoải mái, idempotent
  • POST /orders → nguy hiểm! Retry có thể tạo 2 đơn hàng
  • PUT /orders/123 → thường an toàn (replace toàn bộ)
  • DELETE /orders/123 → thường an toàn (xóa rồi xóa lại = vẫn xóa)

Với non-idempotent operations, bạn cần idempotency key:

# Client gửi kèm idempotency key
headers = {"Idempotency-Key": "order-abc-123-attempt-1"}
response = await client.post("/orders", json=data, headers=headers)

# Server side: check key trước khi xử lý
async def create_order(request):
    key = request.headers.get("Idempotency-Key")
    existing = await cache.get(f"idempotency:{key}")
    if existing:
        return existing  # trả về kết quả cũ, không tạo order mới

    result = await process_order(request.json())
    await cache.set(f"idempotency:{key}", result, ttl=86400)
    return result

Stripe, PayPal, và hầu hết payment API đều dùng pattern này. Nếu bạn build API cho người khác gọi, hãy support idempotency key.

Circuit Breaker: ngắt mạch khi service chết

Retry xử lý lỗi tạm thời. Nhưng nếu Service B chết hẳn 5 phút? Retry 3 lần, mỗi lần timeout 5 giây = 15 giây chờ vô ích cho mỗi request. Nhân với hàng nghìn request, Service A bị nghẽn hoàn toàn.

Circuit breaker giải quyết bằng cách ngừng gọi service đã chết, thay vì cứ cố retry.

3 trạng thái

Giống cầu dao điện trong nhà bạn:

                  failure threshold
    ┌──────┐      exceeded        ┌──────┐
    │CLOSED│ ───────────────────→ │ OPEN │
    │      │                      │      │
    └──┬───┘                      └──┬───┘
       ↑                             │
       │    success                  │ timeout
       │                             │ expires
       │         ┌─────────┐         │
       └──────── │HALF-OPEN│ ←───────┘
                 │         │
                 └─────────┘
                  failure → back to OPEN
  • Closed (bình thường): request đi qua, đếm lỗi. Nếu lỗi vượt ngưỡng → chuyển sang Open.
  • Open (ngắt mạch): chặn toàn bộ request, trả lỗi ngay lập tức. Không timeout, không chờ. Sau một khoảng thời gian → chuyển sang Half-Open.
  • Half-Open (thử lại): cho một vài request đi qua. Nếu thành công → Closed. Nếu fail → quay lại Open.

Implementation

Đây là circuit breaker đơn giản bằng TypeScript:

type State = "closed" | "open" | "half-open";

class CircuitBreaker {
  private state: State = "closed";
  private failureCount = 0;
  private lastFailureTime = 0;
  private readonly failureThreshold: number;
  private readonly resetTimeout: number; // ms

  constructor(options: { failureThreshold?: number; resetTimeout?: number } = {}) {
    this.failureThreshold = options.failureThreshold ?? 5;
    this.resetTimeout = options.resetTimeout ?? 30_000; // 30s
  }

  async call<T>(fn: () => Promise<T>, fallback?: () => T): Promise<T> {
    if (this.state === "open") {
      // Check nếu đã hết timeout → chuyển sang half-open
      if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
        this.state = "half-open";
      } else {
        if (fallback) return fallback();
        throw new Error("Circuit breaker is OPEN — request blocked");
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      if (fallback) return fallback();
      throw error;
    }
  }

  private onSuccess() {
    this.failureCount = 0;
    this.state = "closed";
  }

  private onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    if (this.failureCount >= this.failureThreshold) {
      this.state = "open";
      console.warn(
        `Circuit OPEN after ${this.failureCount} failures. ` +
        `Will retry in ${this.resetTimeout / 1000}s.`
      );
    }
  }
}

Sử dụng:

const breaker = new CircuitBreaker({ failureThreshold: 5, resetTimeout: 30_000 });

// Gọi với fallback
const data = await breaker.call(
  () => fetch("https://service-b/api/data").then(r => r.json()),
  () => ({ items: [], fromCache: true }) // fallback khi circuit open
);

Khi circuit open, request fail ngay lập tức — không timeout, không tốn resource. Service A vẫn hoạt động (dù degraded), user vẫn nhận được response.

Kết hợp Retry + Circuit Breaker

Hai pattern này bổ trợ nhau hoàn hảo:

  • Retry (inner): xử lý lỗi tạm thời — network blip, 503 thoáng qua
  • Circuit breaker (outer): xử lý lỗi kéo dài — service chết, dependency sập

Thứ tự quan trọng: circuit breaker bọc bên ngoài retry.

Request → [Circuit Breaker] → [Retry với backoff] → Service B

Nếu ngược lại (retry bọc circuit breaker), mỗi retry đều check circuit breaker — vẫn ổn. Nhưng pattern phổ biến hơn là circuit breaker đếm số lần retry cuối cùng vẫn fail, không đếm từng attempt riêng lẻ.

const breaker = new CircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });

async function callServiceB(path: string) {
  return breaker.call(async () => {
    // Retry logic bên trong
    return callWithRetry(`https://service-b${path}`, {
      maxRetries: 2,
      baseDelay: 500,
    });
  });
}

Logic: retry 2 lần trước khi báo fail cho circuit breaker. Circuit breaker đếm 3 lần “retry đã hết mà vẫn fail” → open circuit.

Bài học từ production

Sau khi triển khai ở vài dự án, đây là những điều mình đúc kết:

1. Log đầy đủ state transitions. Khi circuit breaker chuyển từ closed → open, đó là signal quan trọng. Gắn alert vào đây — nó cho bạn biết dependency có vấn đề trước khi user report.

2. Đừng set threshold quá thấp. failureThreshold: 2 nghĩa là 2 request fail liên tiếp là ngắt mạch. Trong hệ thống real-world, 2 lỗi liên tiếp xảy ra thường xuyên hơn bạn tưởng. Mình thường bắt đầu với 5-10.

3. Reset timeout nên tăng dần. Lần đầu open: đợi 30s. Nếu half-open vẫn fail, lần sau đợi 60s, rồi 120s. Tránh liên tục probe service đang chết.

4. Fallback phải hữu ích. Trả về cached data, default value, hoặc response từ service backup. Đừng chỉ throw error — hãy degrade gracefully.

5. Đừng tự build cho production. Ví dụ trên là để hiểu concept. Khi dùng thật, hãy dùng thư viện đã battle-tested:

  • Python: tenacity (retry), pybreaker (circuit breaker)
  • Node.js: cockatiel, opossum
  • Java: Resilience4j
  • Infra level: Istio, Envoy proxy có built-in circuit breaker

Tóm lại

PatternXử lýKhi nào dùng
Retry + backoffLỗi tạm thờiNetwork blip, 503 thoáng qua
Circuit breakerLỗi kéo dàiService chết, dependency sập
Kết hợp cả haiMọi trường hợpProduction system nghiêm túc

Hai pattern này không fancy, không trendy, nhưng là nền tảng của bất kỳ hệ thống phân tán nào muốn chạy ổn định. Nếu hệ thống của bạn có nhiều hơn 2 services gọi nhau, hãy triển khai ngay — trước khi 3 giờ sáng bị gọi dậy vì cascading failure.

// reactions


cat comments.log


hoatq@dev : ~/blog $