DEV Community

EME GUG
EME GUG

Posted on

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:

# 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()
Enter fullscreen mode Exit fullscreen mode

Đ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:

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));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)