To Retry or Not to Retry: Exponential Backoff, Jitter and Idempotency Keys Done Right
Hầu như dev nào cũng từng viết một vòng for i in range(3) bọc quanh một HTTP call, rồi tự tin là hệ thống đã "resilient". Mình cũng vậy, cho đến một đêm payment gateway của đối tác chậm khoảng 2 giây. Service của tụi mình retry ngay lập tức, 3 lần, trên 40 instance. Một sự cố nhỏ thành ra một cơn retry storm, và gateway sập hẳn. Tệ hơn nữa, vài khách hàng bị trừ tiền 2 lần vì request đầu thật ra đã thành công, chỉ có response là bị timeout.
Retry không phải lúc nào cũng sai, nhưng retry ẩu thì nguy hiểm hơn là không retry. Bài này tóm lại những gì mình rút ra sau vài lần bị ăn hành: khi nào nên retry, retry thế nào cho đúng, và làm sao để retry không gây hậu quả.
Khi nào nên retry, khi nào không
Câu hỏi đầu tiên không phải là "retry mấy lần" mà là: lỗi này có phải lỗi tạm thời không, và thao tác này có idempotent không?
flowchart TD
A[Request thất bại] --> B{Lỗi tạm thời?<br/>timeout, 429, 502, 503, 504}
B -- Không: 400, 401, 404, 422 --> X[Fail ngay, không retry]
B -- Có --> C{Thao tác idempotent?<br/>GET, PUT, DELETE hoặc có Idempotency-Key}
C -- Không --> Y[Không retry tự động<br/>hoặc thêm idempotency key]
C -- Có --> D{Còn budget?<br/>số lần + tổng thời gian}
D -- Hết --> Z[Fail, log, alert]
D -- Còn --> E[Chờ backoff + jitter] --> F[Retry]
```
Vài quy tắc mình áp dụng:
- **Không retry lỗi 4xx** (trừ 408 và 429). Gửi lại một request sai format 5 lần thì nó vẫn sai.
- **Tôn trọng header `Retry-After`** khi gặp 429 hoặc 503. Server đã nói rõ khi nào nên quay lại, đừng đoán.
- **Timeout là trường hợp khó nhất.** Bạn không biết request đã được xử lý hay chưa. Với `POST /payments` mà không có idempotency key thì đừng bao giờ retry tự động.
- **Chỉ retry ở một tầng.** Nếu client retry 3 lần, API gateway retry 3 lần, service retry 3 lần thì một request gốc có thể thành 27 request ở downstream. Retry nhân lên theo cấp số nhân qua các tầng.
## Exponential backoff + jitter: đừng để mọi client cùng retry một lúc
Retry ngay lập tức gần như luôn sai. Nếu server đang quá tải, bạn chỉ dội thêm tải vào nó. Exponential backoff (chờ 0.5s, 1s, 2s, 4s...) giúp giãn tải ra, nhưng vẫn còn một vấn đề: nếu 1000 client cùng fail vào một thời điểm thì chúng cũng sẽ cùng retry vào đúng các mốc giống nhau. Đây là "thundering herd".
Giải pháp là **jitter**: thêm yếu tố ngẫu nhiên vào thời gian chờ. Theo phân tích nổi tiếng của AWS Architecture Blog, "full jitter" (`sleep = random(0, min(cap, base * 2^attempt))`) cho tổng số request thấp nhất mà thời gian hoàn thành vẫn chấp nhận được.
Với Python, mình dùng `tenacity` (bản 9.x) thay vì tự viết:
```python
# 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 = {408, 429, 500, 502, 503, 504}
def is_retryable(exc: BaseException) -> bool:
if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout)):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in RETRYABLE_STATUS
return False
@retry(
retry=retry_if_exception(is_retryable),
wait=wait_random_exponential(multiplier=0.5, max=10), # full jitter, cap 10s
stop=(stop_after_attempt(4) | stop_after_delay(20)), # budget: 4 lần HOẶC 20s
before_sleep=before_sleep_log(log, logging.WARNING),
reraise=True,
)
def fetch_exchange_rate(client: httpx.Client, currency: str) -> dict:
resp = client.get(f'/rates/{currency}', timeout=3.0)
resp.raise_for_status()
return resp.json()
```
Điểm quan trọng: `stop` kết hợp cả **số lần** và **tổng thời gian**. Nếu user đang chờ một request có timeout 30s ở frontend thì việc bạn retry đến giây thứ 45 là vô nghĩa.
## Phía JavaScript: fetch, AbortSignal và Retry-After
Với Node.js 20+ hoặc trình duyệt hiện đại, bạn không cần thêm thư viện. `AbortSignal.timeout()` và `AbortSignal.any()` đã có sẵn:
```javascript
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function backoffMs(attempt, baseMs = 300, capMs = 8000) {
const exp = Math.min(capMs, baseMs * 2 ** attempt);
return Math.random() * exp; // full jitter
}
function parseRetryAfter(header) {
if (!header) return null;
const secs = Number(header);
if (!Number.isNaN(secs)) return secs * 1000;
const date = Date.parse(header);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
export async function fetchWithRetry(url, opts = {}, { maxAttempts = 4, totalTimeoutMs = 15000 } = {}) {
const deadline = AbortSignal.timeout(totalTimeoutMs);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const signal = AbortSignal.any([deadline, AbortSignal.timeout(3000)]);
const res = await fetch(url, { ...opts, signal });
if (!RETRYABLE.has(res.status) || attempt === maxAttempts - 1) return res;
const wait = parseRetryAfter(res.headers.get('retry-after')) ?? backoffMs(attempt);
await sleep(wait);
} catch (err) {
if (deadline.aborted || attempt === maxAttempts - 1) throw err;
await sleep(backoffMs(attempt));
}
}
}
```
Có hai signal: một cái cho timeout từng attempt (3s), một cái cho deadline tổng (15s). Khi hết deadline thì dừng hẳn, không cố thêm lần nào nữa.
## Idempotency key: điều kiện để retry POST an toàn
Quay lại bài toán thanh toán. Client gửi `POST /payments`, server xử lý xong, trừ tiền xong, nhưng response bị mất do network. Client timeout rồi retry, và thế là khách bị trừ tiền 2 lần.
Cách giải mà Stripe đã phổ biến (và đang được chuẩn hoá trong IETF draft `Idempotency-Key` header) là client tự sinh một key duy nhất cho mỗi thao tác logic, rồi gửi lại **đúng key đó** mỗi lần retry. Server dùng key để nhận ra request trùng.
```mermaid
sequenceDiagram
participant C as Client
participant S as API Server
participant R as Redis
participant DB as Database
C->>S: POST /payments (Idempotency-Key: abc123)
S->>R: SET idem:abc123 processing NX EX 86400
R-->>S: OK (key mới)
S->>DB: Tạo payment
S->>R: SET idem:abc123 = response
S--xC: Response bị mất (timeout)
C->>S: Retry POST (cùng key abc123)
S->>R: GET idem:abc123
R-->>S: Response đã lưu
S-->>C: 201 (trả lại response cũ, không trừ tiền lần 2)
```
Phía server, một bản tối giản dùng FastAPI và `redis-py` 5.x:
```python
import json
from fastapi import FastAPI, Header, HTTPException
from redis.asyncio import Redis
app = FastAPI()
redis = Redis(host='localhost', decode_responses=True)
TTL = 24 * 3600
@app.post('/payments', status_code=201)
async def create_payment(body: dict, idempotency_key: str = Header(...)):
cache_key = f'idem:{idempotency_key}'
# Chỉ một request được "giành" key này
acquired = await redis.set(cache_key, 'processing', nx=True, ex=TTL)
if not acquired:
cached = await redis.get(cache_key)
if cached == 'processing':
raise HTTPException(409, 'Request đang được xử lý, thử lại sau')
return json.loads(cached)
try:
result = await charge_customer(body) # logic thật
except Exception:
await redis.delete(cache_key) # cho phép retry nếu fail thật
raise
await redis.set(cache_key, json.dumps(result), ex=TTL)
return result
```
Có vài chi tiết hay bị bỏ sót:
- Key phải được sinh **một lần** ở client (ví dụ `crypto.randomUUID()`) trước vòng retry, không sinh lại ở mỗi attempt.
- Nên lưu kèm hash của request body. Nếu cùng key mà body khác thì trả về 422, vì đó là bug ở phía client.
- Với hệ thống tiền bạc thật, nên lưu idempotency record vào cùng transaction với DB chứ không chỉ dựa vào Redis.
## Kết luận
Retry là công cụ mạnh, nhưng nếu không có ràng buộc thì nó biến lỗi nhỏ thành sự cố lớn. Checklist mình dùng khi review code có retry:
1. **Phân loại lỗi trước:** chỉ retry lỗi tạm thời (network, 408, 429, 5xx). Lỗi 4xx thì fail ngay.
2. **Luôn có backoff + full jitter.** Không bao giờ retry ngay lập tức, không bao giờ để sleep cố định.
3. **Đặt budget kép:** giới hạn số lần *và* tổng thời gian. Tôn trọng `Retry-After`.
4. **Retry ở đúng một tầng.** Ghi rõ trong tài liệu kiến trúc tầng nào chịu trách nhiệm retry.
5. **Thao tác không idempotent thì phải có Idempotency-Key**, nếu không thì đừng retry tự động.
6. **Log mỗi lần retry** kèm attempt number. Khi tỉ lệ retry tăng vọt, đó thường là dấu hiệu sớm nhất cho thấy downstream đang có vấn đề, thậm chí trước cả khi alert error rate kêu.
Bắt đầu từ việc nhỏ: grep codebase tìm các vòng `for` hoặc `while` quanh HTTP call, rồi thay bằng `tenacity` hoặc helper như ví dụ ở trên. Làm mất chừng nửa ngày, nhưng có thể giúp bạn tránh được một đêm on-call rất dài.
All rights reserved