devto 2026-10-09 원문 보기 ↗
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.
Trước khi viết dòng code nào, cần trả lời hai câu hỏi:
503 Service Unavailable, 429 Too Many Requests, ECONNRESET thì đáng retry. Còn 400, 401, 404, 422 thì retry 100 lần vẫn sai y như vậy.GET, PUT, DELETE theo chuẩn HTTP là idempotent. POST thì không. Retry một request POST /charges khi 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.
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_exponential là 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.reraise=True để caller nhận đúng exception gốc chứ không phải RetryError.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:
retryThrottling trong service config, Envoy/Istio cũng có retry_budget.pybreaker trong Python, opossum trong 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.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.
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:
Retry-After khi server gửi về.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ỗ.