Thực hành: circuit breaker, bulkhead và retry budget khi API LLM hoặc API của khách sập
Lỗi 429 do chạm spend cap sẽ không tự hết sau vài giây backoff, nhưng nếu ba tầng code cùng retry thì một request của người dùng có thể thành 27 lần gọi vào một dịch vụ đang hỏng.
- 1BulkheadMỗi dependency có pool riêng, pool đầy thì fail fast và trả 503 lên trên
- 2Circuit breakerĐang Open thì chặn ngay, Half-Open chỉ cho một request thử đi qua
- 3Gọi APIChỉ bật retry ở SDK hoặc ở tầng app, không bật cả hai
- 4Phân loại lỗi400 thì không retry, 429 không có retry-after thì mở breaker ngay
- 5Retry budgetLỗi tạm thời được retry có backoff và jitter, bucket cạn thì dừng
Mỗi lớp chặn một kiểu lan lỗi, và retry chỉ diễn ra ở đúng một chỗ.
Đồ hoạ: FDE Times
Tóm tắt nhanh
- Mỗi tầng cho phép tối đa 3 lần gọi thì 3 tầng xếp chồng nhân lên thành 27 lần gọi vào một dịch vụ đang yếu, nên chỉ retry ở một chỗ duy nhất.
- Lỗi 400 và lỗi 429 do chạm spend cap thì không retry: lỗi đầu cần sửa request, lỗi sau là tín hiệu để mở breaker.
- Cho API LLM và API của khách mỗi bên một pool riêng, để bên này sập không chiếm hết tài nguyên của bên kia.
Thử hình dung lúc 9 giờ sáng ở chỗ khách: agent hỗ trợ khách hàng bắt đầu nhận lỗi 429 từ API LLM.
Tài liệu lỗi của Claude API ghi rõ rằng loại 429 sinh ra khi chạm spend cap của tier thì không kèm header retry-after, và sẽ còn lỗi cho tới khi quyền truy cập được mở lại.
Vậy mà code vẫn retry, và mỗi lần retry lại giữ thêm một connection.
Đến 9 giờ 05, API CRM của chính khách, vốn không dính gì tới LLM, cũng bắt đầu timeout, vì worker pool đã kẹt hết vào những request LLM đang ngồi chờ backoff. Sự cố ở một dependency đã lan ra toàn hệ thống.
Với FDE, đây là kịch bản rất thật: bạn tích hợp cùng lúc API LLM và hệ thống nội bộ của khách, và cả hai đều có thể sập.
Bài này hướng dẫn dựng ba lớp bảo vệ bằng Python thuần trong khoảng 100 dòng: retry có ngân sách, circuit breaker và bulkhead, cộng thêm một bước phân loại lỗi đứng trước cả ba. Bạn chỉ cần Python 3.10+, không cần thư viện ngoài.
Code đã được rút gọn cho dễ đọc, chưa phải bản dùng cho production.
Vì sao retry lại làm sự cố tệ hơn?
Microsoft gọi hiện tượng này là retry storm: khi dịch vụ đang bận hoặc không phản hồi, client retry dồn dập khiến nó không hồi phục được, và sự cố còn nặng thêm. AWS Builders’ Library cũng rút ra kết luận tương tự: retry vào lúc hệ thống đang quá tải chỉ khiến tình hình tệ hơn.
Với API LLM còn có một cái bẫy về quota. Tài liệu rate limit của OpenAI lưu ý rằng request thất bại vẫn bị tính vào giới hạn mỗi phút, nên cứ gửi lại liên tục thì không giải quyết được gì. Mỗi lần retry vô ích vừa đốt quota vừa đẩy lúc hồi phục ra xa hơn.
Giờ thử nhân số lên. SDK chính thức của Claude mặc định tự retry 2 lần, tức tối đa 3 lần gọi.
Nếu bạn bọc thêm một decorator cũng cho phép tối đa 3 lần gọi, rồi queue worker bên ngoài lại chạy tối đa 3 lần, thì mỗi request của người dùng có thể thành 3 × 3 × 3 = 27 lần gọi vào một dịch vụ đang yếu.
Vì vậy AWS khuyến nghị chỉ retry tại một điểm duy nhất trong stack.
Bước 1: phân loại lỗi trước khi nghĩ tới retry
Không phải lỗi nào cũng đáng retry. Microsoft nêu ví dụ lỗi 400 Bad Request: server đã báo request không hợp lệ thì gửi lại y nguyên cũng vô ích. Còn với Claude API, lỗi 429 do spend cap là lỗi cứng, không phải lỗi tạm thời.
def classify(status: int, headers: dict) -> str:
if status < 400:
return "ok"
if status == 400:
return "client_bug" # sửa request, không retry
if status == 429 and "retry-after" not in headers:
return "hard_stop" # vd. spend cap: mở breaker
if status == 429 or status >= 500:
return "transient" # có thể retry, có giới hạn
return "client_bug"
Lưu ý đây là bản đơn giản hóa. Quy tắc “429 không có retry-after là lỗi cứng” dựa trên mô tả của Claude API, nên với API của nhà cung cấp khác hoặc API của khách, bạn phải đọc tài liệu lỗi của họ rồi chỉnh lại.
Sau bước này, hãy viết vài unit test đưa vào từng mã lỗi và kiểm tra rằng client_bug và hard_stop không bao giờ đi vào vòng retry.
Bước 2: chọn đúng một tầng được retry
Microsoft khuyên chỉ đặt logic retry ở nơi hiểu đủ ngữ cảnh của thao tác đang lỗi, còn các tầng thấp hơn nên fail fast để tránh retry lồng nhau gây độ trễ dài. Microsoft cũng nhắc rằng retry đòi hỏi thao tác phải idempotent.
SDK của Claude cho phép chỉnh số lần retry qua max_retries. Nếu bạn muốn tự kiểm soát retry ở tầng ứng dụng, hãy tắt retry của SDK:
import anthropic
client = anthropic.Anthropic(max_retries=0) # tầng app lo retry
Cách ngược lại cũng hợp lệ: giữ retry của SDK (có exponential backoff và tôn trọng retry-after) và không bọc thêm gì bên ngoài. Chỉ cần nhớ một điều: đừng để cả hai tầng cùng retry.
Để kiểm tra, grep codebase tìm retry, tenacity, max_retries và cấu hình queue, rồi ghi ra số lần gọi tối đa cho mỗi request. Con số đó phải bằng số lần gọi của đúng một tầng.
Bước 3: retry budget bằng token bucket
Checklist chống retry storm của Microsoft gồm giới hạn số lần và tổng thời gian retry, dùng exponential backoff và tôn trọng retry-after. AWS thêm một lớp nữa: giới hạn retry ngay tại client bằng token bucket.
Đoạn code dưới đây là một cách cài đặt rút gọn theo ý đó, không phải công thức của AWS: mỗi lần retry tốn một token, còn request thành công thì nạp lại một phần token. Khi dependency sập lâu, bucket cạn và client tự ngừng retry.
import random, time
class RetryBudget:
def __init__(self, capacity=10, refill=0.1):
self.tokens, self.capacity, self.refill = capacity, capacity, refill
def spend(self) -> bool:
if self.tokens >= 1:
self.tokens -= 1
return True
return False
def on_success(self):
self.tokens = min(self.capacity, self.tokens + self.refill)
def backoff(attempt, base=0.5, cap=20.0):
return random.uniform(0, min(cap, base * 2 ** attempt)) # jitter
Các tham số capacity=10 và refill=0.1 chỉ để minh họa, bạn cần chỉnh theo traffic thật. Jitter giúp các client không cùng retry vào một thời điểm. Nếu response có retry-after, hãy chờ theo giá trị đó thay vì giá trị của backoff.
Bước 4: circuit breaker ba trạng thái
Microsoft định nghĩa circuit breaker là cơ chế tạm chặn truy cập tới dịch vụ từ xa khi số lỗi đã chạm ngưỡng, thay vì tiếp tục retry một thao tác nhiều khả năng sẽ thất bại. Ở trạng thái Closed, breaker đếm lỗi.
Nếu số lỗi trong một khoảng thời gian vượt ngưỡng, breaker chuyển sang Open và bắt đầu đếm giờ. Hết giờ, breaker sang Half-Open; trong đoạn code dưới đây, nó chỉ cho đúng một request thử đi qua, để dịch vụ đang hồi phục không bị dồn request ồ ạt.
class CircuitBreaker:
def __init__(self, threshold=5, window=30, open_secs=20):
self.threshold, self.window, self.open_secs = threshold, window, open_secs
self.fails, self.state, self.opened_at = [], "closed", 0.0
def allow(self) -> bool:
now = time.monotonic()
if self.state == "open" and now - self.opened_at >= self.open_secs:
self.state = "half_open"
return True # đúng một request thử
return self.state == "closed"
def record(self, kind: str):
now = time.monotonic()
if kind == "ok":
self.state, self.fails = "closed", []
return
if kind == "hard_stop" or self.state == "half_open":
self.trip(now)
return
self.fails = [t for t in self.fails if now - t < self.window] + [now]
if len(self.fails) >= self.threshold:
self.trip(now)
def trip(self, now):
self.state, self.opened_at = "open", now
Điểm cần chú ý là hard_stop mở breaker ngay lập tức mà không chờ đủ ngưỡng, vì retry lỗi spend cap không đem lại gì. Microsoft cũng nói rõ retry và breaker có mục đích khác nhau: khi breaker báo lỗi không còn là lỗi tạm thời thì logic retry phải dừng lại. Trong vòng gọi, hãy kiểm tra breaker.allow() trước mỗi lần thử, kể cả các lần retry.
Bước 5: bulkhead, mỗi dependency một khoang riêng
Tên bulkhead lấy từ vách ngăn trong thân tàu: chia ứng dụng thành các pool để khi một phần hỏng, các phần còn lại vẫn chạy được. Ví dụ Microsoft đưa ra rất sát việc của FDE: client gọi nhiều dịch vụ thì cấp cho mỗi dịch vụ một connection pool riêng, để dịch vụ nào lỗi chỉ ảnh hưởng tới pool của nó.
Với workload AI và inference, Microsoft khuyến nghị bulkhead chặt vì quota và giới hạn đồng thời được tính theo từng deployment, và nên cô lập theo workload hoặc theo tenant.
import asyncio
POOLS = {"llm": asyncio.Semaphore(8), "crm_khach": asyncio.Semaphore(4)}
async def with_bulkhead(name, coro_fn):
sem = POOLS[name]
if sem.locked():
raise RuntimeError(f"{name} đầy: trả 503 lên trên")
async with sem:
return await coro_fn()
Khi pool đầy, code fail fast và báo lỗi lên trên chứ không xếp hàng chờ. Đây đúng là lời khuyên trong throttling pattern của Microsoft: khi dependency bên ngoài lỗi, hãy tự giảm lượng request đang gửi đi, và truyền tín hiệu 429/503 lên tầng trên thay vì nuốt mất.
Quay lại kịch bản lúc 9 giờ sáng: với hai semaphore riêng, API LLM có sập thì cũng chỉ chiếm tối đa 8 slot, còn 4 slot của CRM vẫn trống.
Bước 6: kiểm tra bằng một dependency giả
Viết một hàm giả luôn trả 503, gọi nó 50 lần qua toàn bộ chuỗi bulkhead → breaker → classify → retry budget, rồi đếm số lần hàm giả thực sự bị gọi. Làm phép tính trước để có mốc so sánh: nếu không có lớp bảo vệ nào và stack có ba tầng retry như ở trên, con số tối đa là 50 × 27 = 1.350 lần.
Khi đã gắn đủ các lớp, với tham số trong bài, con số này phải dừng ở khoảng 5 lần, tức ngưỡng của breaker, cộng thêm một lần thử mỗi khi breaker sang Half-Open.
Retry budget là lưới an toàn thứ hai: kể cả khi breaker cài sai, bucket cũng chỉ cho tối đa 10 lần retry. Nếu bạn đếm được vài chục lần trở lên, có một tầng retry nào đó đang lọt qua breaker.
Sau đó cho hàm giả trả 429 không có retry-after và kiểm tra rằng breaker mở ngay từ lần gọi đầu tiên.
Có ba lỗi hay gặp. Một là đếm lỗi 400 vào breaker, khiến một bug ở client làm mở mạch cho tất cả mọi người. Hai là dùng chung một breaker cho mọi tenant, nên một tenant vượt quota kéo theo cả những tenant khác. Ba là quên rằng request thất bại vẫn bị tính vào rate limit.
Khách sẽ hỏi gì vào hôm API sập?
Ở dự án thật, khách ít khi hỏi “anh có circuit breaker chưa”. Họ sẽ hỏi vì sao hôm API LLM chập chờn, cả cổng hỗ trợ của họ cũng chết theo. Câu trả lời tốt là một sơ đồ: mỗi dependency có pool riêng, retry nằm ở đúng một tầng, và breaker mở khi gặp lỗi cứng.
Khi đọc JD của các vị trí FDE, hãy để ý những cụm như “production reliability” hay “integrate with customer systems”. Trong CV, đừng chỉ ghi “đã implement circuit breaker”. Hãy ghi con số trước và sau, chẳng hạn “giảm số lần gọi tối đa cho mỗi request từ 27 xuống 3”, vì đó là thứ người phỏng vấn có thể hỏi sâu.
Lần tới khi API của khách sập, mục tiêu không phải là để hệ thống của bạn cố gắng hơn. Mục tiêu là để nó biết dừng lại đúng lúc.
8 nguồn
- Circuit Breaker Pattern - Azure Architecture Center | Microsoft Learn · 2025-02-05
- Bulkhead Pattern - Azure Architecture Center | Microsoft Learn · 2026-03-19
- Retry Storm Antipattern - Azure Architecture Center | Microsoft Learn · 2025-07-16
- Retry pattern - Azure Architecture Center | Microsoft Learn · 2024-07-18
- Throttling Pattern - Azure Architecture Center | Microsoft Learn · 2026-05-29
- Timeouts, retries and backoff with jitter · 2026-06-12
- Claude API errors
- Rate limits | OpenAI API