Hồi trước team mình từng có một sự cố khá kinh điển: payment provider chập chờn khoảng 30 giây, và trong 30 giây đó service của bọn mình gửi 4 lần request charge tiền cho cùng một đơn hàng. Lý do là có 3 tầng cùng retry: SDK của client, service gọi API, và cả job queue phía sau. Không tầng nào sai nếu xét riêng, nhưng gộp lại thì thành thảm họa. Sau lần đó mình rút ra một điều: retry không phải tính năng "thêm vào cho chắc". Làm sai thì nó còn nguy hiểm hơn là không retry gì cả.
Bài này mình chia sẻ cách mình đang làm retry trong production: khi nào nên retry, retry thế nào để không tự DDoS chính hệ thống của mình, và làm sao để retry an toàn với những request có side effect.
Câu hỏi đầu tiên: request này có nên retry không?
Trước khi viết dòng code nào, cần trả lời hai câu hỏi:
-
Lỗi này có phải lỗi tạm thời (transient) không? Timeout,
503 Service Unavailable,429 Too Many Requests,ECONNRESETthì đáng retry. Còn400,401,404,422thì retry 100 lần vẫn sai y như vậy. -
Request này có idempotent không?
GET,PUT,DELETEtheo chuẩn HTTP là idempotent.POSTthì không. Retry một requestPOST /chargeskhi bị timeout là cách nhanh nhất để charge khách hai lần, vì timeout không có nghĩa là server chưa xử lý. Rất có thể server đã xử lý xong, chỉ có response là không về tới bạn.
flowchart TD
A[Request thất bại] --> B{Lỗi transient?<br/>timeout, 429, 502, 503, 504}
B -- Không --> X[Fail ngay, không retry]
B -- Có --> C{Request idempotent<br/>hoặc có Idempotency-Key?}
C -- Không --> X
C -- Có --> D{Còn retry budget?}
D -- Không --> X
D -- Có --> E[Chờ backoff + jitter]
E --> F[Retry]
Nguyên tắc của mình: mặc định là không retry, chỉ retry khi đã qua đủ cả ba điều kiện trên.
Exponential backoff và vì sao jitter là bắt buộc
Retry ngay lập tức gần như luôn là ý tưởng tồi. Server đang quá tải mà bạn bắn thêm request thì nó càng chết nhanh hơn. Exponential backoff giải quyết việc này: chờ 1s, 2s, 4s, 8s...
Nhưng chỉ backoff thôi thì chưa đủ. Giả sử 10.000 client cùng bị lỗi lúc 10:00:00. Với backoff thuần, cả 10.000 client sẽ cùng retry lúc 10:00:01, rồi cùng lúc 10:00:03... Đây là hiện tượng thundering herd: server vừa hồi phục đã bị đánh sập lại. Jitter (thêm ngẫu nhiên vào thời gian chờ) sẽ rải các request này ra.
Trong Python, mình dùng tenacity (bản 9.x) vì nó khai báo rất gọn:
# pip install tenacity==9.0.0 httpx==0.27.2
import httpx
from tenacity import (
retry, stop_after_attempt, stop_after_delay,
wait_random_exponential, retry_if_exception, before_sleep_log,
)
import logging
log = logging.getLogger(__name__)
RETRYABLE_STATUS = {429, 502, 503, 504}
def is_transient(exc: BaseException) -> bool:
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in RETRYABLE_STATUS
return False
@retry(
retry=retry_if_exception(is_transient),
wait=wait_random_exponential(multiplier=0.5, max=20), # full jitter
stop=(stop_after_attempt(5) | stop_after_delay(30)),
before_sleep=before_sleep_log(log, logging.WARNING),
reraise=True,
)
def fetch_user(client: httpx.Client, user_id: int) -> dict:
resp = client.get(f'/users/{user_id}', timeout=3.0)
resp.raise_for_status()
return resp.json()
Vài điểm cần để ý:
-
wait_random_exponentiallà full jitter: thời gian chờ là một số ngẫu nhiên trong khoảng[0, min(max, multiplier * 2^n)]. Theo các phân tích về backoff được chia sẻ rộng rãi, full jitter cho tổng số request thấp hơn hẳn so với backoff thuần. - Luôn có hai điều kiện dừng: số lần thử và tổng thời gian. Nếu không, một request có thể treo cả phút trong khi user đã tắt tab từ lâu.
-
reraise=Trueđể caller nhận đúng exception gốc chứ không phảiRetryError.
Tôn trọng Retry-After và giới hạn retry budget
Khi server trả về 429 hoặc 503 kèm header Retry-After, nghĩa là server đang nói thẳng cho bạn biết khi nào nên quay lại. Lờ đi header đó là thiếu lịch sự, và nhiều API (GitHub, Stripe, OpenAI...) sẽ ban token của bạn nếu cứ cố tình spam.
Ví dụ phía Node.js (v20+, dùng fetch có sẵn):
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const RETRYABLE = new Set([429, 502, 503, 504]);
function parseRetryAfter(header) {
if (!header) return null;
const secs = Number(header);
if (!Number.isNaN(secs)) return secs * 1000;
const date = Date.parse(header); // dạng HTTP-date
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
export async function fetchWithRetry(url, opts = {}, { maxAttempts = 4, baseMs = 300, capMs = 10_000 } = {}) {
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(url, { ...opts, signal: AbortSignal.timeout(5000) });
if (!RETRYABLE.has(res.status) || attempt >= maxAttempts) return res;
const serverHint = parseRetryAfter(res.headers.get('retry-after'));
const backoff = Math.random() * Math.min(capMs, baseMs * 2 ** attempt);
await sleep(Math.min(capMs, serverHint ?? backoff));
} catch (err) {
// TimeoutError / lỗi mạng: chỉ retry nếu còn lượt
if (attempt >= maxAttempts) throw err;
await sleep(Math.random() * Math.min(capMs, baseMs * 2 ** attempt));
}
}
}
Ngoài ra, quay lại câu chuyện 4 lần charge ở đầu bài: vấn đề lớn nhất là retry ở nhiều tầng. Nếu mỗi tầng retry 3 lần và có 3 tầng thì một request gốc có thể biến thành 3³ = 27 request. Quy tắc mình áp dụng:
- Chỉ retry ở một tầng, thường là tầng gần với dependency nhất. Các tầng trên fail nhanh.
- Dùng retry budget: ví dụ retry không được vượt quá 10% tổng số request trong một cửa sổ thời gian. gRPC hỗ trợ sẵn qua
retryThrottlingtrong service config, Envoy/Istio cũng córetry_budget. - Kết hợp với circuit breaker (ví dụ
pybreakertrong Python,opossumtrong Node): khi tỉ lệ lỗi vượt ngưỡng thì ngừng gọi hẳn một lúc thay vì tiếp tục retry vô ích.
Idempotency Key: làm cho POST retry được an toàn
Với những thao tác có side effect như tạo đơn hàng, charge tiền hay gửi email, cách chuẩn là dùng Idempotency Key. Client sinh một UUID cho mỗi thao tác logic (không phải mỗi lần gửi request) và gửi qua header Idempotency-Key. Server lưu kết quả theo key đó, nên nếu nhận lại cùng key thì trả về kết quả cũ thay vì xử lý lại.
sequenceDiagram
participant C as Client
participant S as API Server
participant R as Redis
C->>S: POST /charges, Idempotency-Key: abc
S->>R: SET idem:abc PROCESSING NX
S-->>C: timeout, mất response
C->>S: Retry POST /charges, Idempotency-Key: abc
S->>R: GET idem:abc
R-->>S: response đã lưu
S-->>C: 201 Created, cùng kết quả cũ
Phía server, một implementation tối giản với FastAPI và Redis:
# pip install fastapi==0.115.0 redis==5.1.0
import json
import redis.asyncio as redis
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
r = redis.Redis(decode_responses=True)
TTL = 24 * 3600
@app.post('/charges', status_code=201)
async def create_charge(body: dict, idempotency_key: str = Header(...)):
key = f'idem:{idempotency_key}'
# Chiếm key một cách atomic; chỉ request đầu tiên thành công
acquired = await r.set(key, 'PROCESSING', nx=True, ex=TTL)
if not acquired:
cached = await r.get(key)
if cached == 'PROCESSING':
raise HTTPException(409, 'Request với key này đang được xử lý')
return json.loads(cached)
try:
result = await do_charge(body) # logic thật, gọi payment provider
except Exception:
await r.delete(key) # cho phép client retry khi lỗi thật sự
raise
await r.set(key, json.dumps(result), ex=TTL)
return result
Trong thực tế bạn nên lưu thêm hash của request body, để nếu client gửi cùng key nhưng body khác thì trả 422 (Stripe làm đúng như vậy). Với hệ thống cần độ bền cao hơn Redis, hãy lưu idempotency record trong cùng transaction PostgreSQL với business data, dùng UNIQUE constraint trên cột key.
Kết luận
Retry trông đơn giản nhưng là một trong những nguồn gây sự cố dây chuyền phổ biến nhất trong distributed system. Checklist mình dùng khi review code:
- Mặc định không retry. Chỉ retry lỗi transient (timeout, 429, 502, 503, 504), không bao giờ retry 4xx khác.
- Luôn dùng exponential backoff + full jitter, có cả giới hạn số lần thử lẫn tổng thời gian.
-
Tôn trọng
Retry-Afterkhi server gửi về. - Retry ở đúng một tầng, có retry budget và circuit breaker để tránh retry storm.
- POST có side effect phải có Idempotency Key. Không có thì không retry, chấm hết.
- Log và đo mọi lần retry. Tỉ lệ retry tăng đột biến thường là tín hiệu sớm nhất cho thấy dependency sắp sập.
Nếu chỉ làm được một việc sau khi đọc bài này, hãy grep codebase của bạn tìm những chỗ đang retry POST mà không có idempotency key. Mình cá là bạn sẽ tìm thấy ít nhất một chỗ.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.