DEV Community

EME GUG
EME GUG

Posted on

To Retry or Not to Retry: Safe Retry Logic with Exponential Backoff, Jitter and Idempotency Keys

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:

  1. Lỗi này có phải lỗi tạm thời (transient) không? Timeout, 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.
  2. Request này có idempotent không? 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.

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

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.
  • 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ải RetryError.

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

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 retryThrottling trong service config, Envoy/Istio cũng có retry_budget.
  • Kết hợp với circuit breaker (ví dụ 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.

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

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:

  1. Mặc định không retry. Chỉ retry lỗi transient (timeout, 429, 502, 503, 504), không bao giờ retry 4xx khác.
  2. 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.
  3. Tôn trọng Retry-After khi server gửi về.
  4. Retry ở đúng một tầng, có retry budget và circuit breaker để tránh retry storm.
  5. POST có side effect phải có Idempotency Key. Không có thì không retry, chấm hết.
  6. 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.