0

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.
    1. 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:
                                
                                ```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 = {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.
                                                                                                    - 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):
                                                                                                    
                                                                                                    ```javascript
                                                                                                    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 `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.
                                                                                                                                                                              
                                                                                                                                                                              ```mermaid
                                                                                                                                                                              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:
                                                                                                                                                                                                                      
                                                                                                                                                                                                                      ```python
                                                                                                                                                                                                                      # 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:
                                                                                                                                                                                                                                                                                                                  
                                                                                                                                                                                                                                                                                                                  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ỗ.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí