Hướng dẫn sử dụng Tenacity trong Python: Xử lý Retry và Exponential Backoff chuẩn production

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Cơn ác mộng lúc 2 giờ sáng: Script chết đứng vì lỗi mạng

Kịch bản này quen thuộc với hầu hết backend developer: bạn dựng một worker đồng bộ 50.000 đơn hàng từ CRM đối tác về PostgreSQL. Chạy thử nghiệm ở local với 100 record thì êm ru. Thế nhưng, khi deploy lên server chạy batch job lúc nửa đêm, worker lăn đùng ra chết sau 3 tiếng xử lý.

Log terminal quăng ra vỏn vẹn một dòng: requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected()) hoặc 502 Bad Gateway. Pipeline gãy gập. Dữ liệu dở dang. Bạn phải bò dậy lúc 2h sáng chỉ để bấm nút rerun. Đắng ngắt ở chỗ: khi chạy lại bằng tay, mọi thứ lại trơn tru như chưa từng có lỗi.

Hiểu đúng về Transient Faults: Lỗi chập chờn nhưng hậu quả thật

Trong hệ thống phân tán hay microservices, kết nối mạng không bao giờ hoàn hảo 100%. Những gián đoạn diễn ra trong vài mili-giây đến vài giây được gọi là Transient Faults (lỗi tạm thời). Ba thủ phạm phổ biến nhất gồm:

  • Chập chờn tầng network: Route mạng tạm thời bị định tuyến lại (BGP flapping) hoặc rớt gói tin tức thời.
  • Rate limit từ bên thứ ba: Server đối tác chạm ngưỡng chịu tải và trả về 429 Too Many Requests hoặc 503 Service Unavailable trong khoảng 2–5 giây rồi tự hồi phục.
  • Database failover: PostgreSQL switch node chính sang replica, hoặc PgBouncer reset pool kết nối định kỳ.

Nếu ứng dụng coi mọi exception đều là lỗi chí mạng (fatal error) rồi dừng script ngay lập tức, service của bạn sẽ cực kỳ mong manh trước môi trường cloud.

Hai giải pháp chắp vá dân dev thường làm (và vì sao nên bỏ)

Cách 1: Vòng lặp while/for bọc try-except thủ công

Cách tiếp cận bản năng nhất của người mới học Python:

import time
import requests

def fetch_order_manual(order_id):
    max_retries = 3
    delay = 2  # giây
    for attempt in range(max_retries):
        try:
            res = requests.get(f"https://api.crm.com/orders/{order_id}", timeout=5)
            res.raise_for_status()
            return res.json()
        except requests.RequestException as e:
            if attempt == max_retries - 1:
                raise
            print(f"Thử lần {attempt + 1} hỏng, chờ {delay}s: {e}")
            time.sleep(delay)

Code này chạy được, nhưng để lại hai vấn đề tai hại khi lên production:

  • Boilerplate tràn lan: Dự án có 30 điểm gọi API và query DB thì bạn phải dán khối for/while này 30 lần. Sửa logic retry một chỗ là phải đi sửa cả 30 file.
  • Hiệu ứng bầy đàn (Thundering Herd): Giả sử 500 worker cùng gặp lỗi mạng lúc 12:00:00. Tất cả đều ngủ đúng 2 giây rồi đồng thanh dội 500 request vào server đối tác lúc 12:00:02. Server đang ngộp thở sẽ sập luôn.

Cách 2: Tự viết Decorator tự chế

Nhiều dev chọn tự viết custom decorator để tái sử dụng. Tuy nhiên, tự viết sẽ nhanh chóng đi vào ngõ cụt khi hệ thống phình to. Bạn sẽ phải tự code thêm cơ chế tính toán cấp số nhân, ngẫu nhiên hóa thời gian chờ (jitter), lọc status code và xử lý async/await. Đừng phát minh lại bánh xe.

Chuẩn hóa cơ chế Retry bằng thư viện Tenacity

Tenacity là thư viện retry tiêu chuẩn của hệ sinh thái Python, phát triển dựa trên thư viện retrying đã dừng bảo trì. Tenacity mạnh nhờ cú pháp khai báo (declarative) dạng decorator: bạn chỉ cần mô tả điều kiện retry, việc điều phối luồng để thư viện lo.

1. Cài đặt

pip install tenacity

2. Retry cơ bản: Giới hạn số lần thử

Để bọc một hàm mạng, bạn dùng decorator @retry kết hợp điều kiện dừng stop_after_attempt:

from tenacity import retry, stop_after_attempt
import requests

@retry(stop=stop_after_attempt(3))
def sync_user_profile(user_id):
    print(f"Đang fetch profile {user_id}...")
    response = requests.get(f"https://api.partner.com/users/{user_id}", timeout=3)
    response.raise_for_status()
    return response.json()

Nếu gặp exception, Tenacity tự bắt lỗi và gọi lại hàm tối đa 3 lần. Vượt quá ngưỡng này, exception gốc sẽ được re-raise để hệ thống giám sát (như Sentry) ghi nhận.

3. Cấu hình Exponential Backoff kết hợp Jitter

Đừng retry ngay lập tức. Chiến lược chuẩn là Exponential Backoff: tăng dần thời gian chờ sau mỗi lần trượt (1s, 2s, 4s, 8s…). Kèm theo đó là Full Jitter (rải ngẫu nhiên độ trễ) để phân tán tải của các worker.

from tenacity import retry, stop_after_attempt, wait_random_exponential
import psycopg2

@retry(
    stop=stop_after_attempt(5),
    wait=wait_random_exponential(multiplier=1, max=10)
)
def execute_db_query(query):
    conn = psycopg2.connect("dbname=sales user=app password=secret host=db-cluster")
    with conn.cursor() as cursor:
        cursor.execute(query)
        return cursor.fetchall()

Với cấu hình trên, worker sẽ thử lại tối đa 5 lần. Thời gian chờ giữa các lần tăng theo hàm mũ cơ số 2 với hệ số multiplier = 1, cộng thêm khoảng ngẫu nhiên và khống chế trần không vượt quá 10 giây.

4. Chỉ retry lỗi tạm thời: Tuyệt đối không retry mù quáng

Đây là sai lầm nguy hiểm nhất: retry cả lỗi nghiệp vụ. Nếu API trả về 401 Unauthorized (token hết hạn) hoặc 422 Unprocessable Entity (payload sai schema), bạn retry cả nghìn lần thì request vẫn hỏng 100%. Tệ hơn, retry một lệnh thanh toán non-idempotent có thể trừ tiền user 2 lần.

Hãy chỉ retry khi gặp lỗi 5xx hoặc mất kết nối mạng (ConnectionError, Timeout):

from tenacity import retry, stop_after_attempt, retry_if_exception_type
import requests

class UpstreamTransientError(Exception):
    """Lỗi mạng hoặc server đối tác quá tải tạm thời."""
    pass

def call_remote_service():
    try:
        res = requests.get("https://api.inventory.com/items", timeout=4)
        # 502, 503, 504 là transient, 4xx thường là lỗi client
        if res.status_code in (502, 503, 504):
            raise UpstreamTransientError(f"Upstream nghẽn: HTTP {res.status_code}")
        res.raise_for_status()
        return res.json()
    except (requests.ConnectionError, requests.Timeout) as exc:
        raise UpstreamTransientError("Rớt kết nối tới upstream") from exc

@retry(
    stop=stop_after_attempt(4),
    retry=retry_if_exception_type(UpstreamTransientError)
)
def get_inventory():
    return call_remote_service()

Trong thực tế, nhiều hệ thống legacy không dùng HTTP status code chuẩn mà trả về HTTP 200 kèm body text như {"status": "FAILED_DB_LOCK_TIMEOUT"}. Bạn sẽ phải dùng regex để bắt mẫu chuỗi lỗi này trước khi quyết định re-raise hay retry. Nếu cần test nhanh regex pattern trên chuỗi log thực tế, bạn có thể quẳng vào regex tester tại toolcraft.app/vi/tools/developer/regex-tester để soi nhóm capture group trực tiếp trên trình duyệt thay vì phải in print debug liên tục.

5. Graceful Fallback: Trả dữ liệu dự phòng khi cạn lượt retry

Khi đã thử đủ số lần mà server đích vẫn bất động, thay vì để cả luồng xử lý lăn ra crash, hãy kích hoạt phương án fallback bằng tham số retry_error_callback:

from tenacity import retry, stop_after_attempt, wait_fixed

def get_cached_rates(retry_state):
    attempt = retry_state.attempt_number
    print(f"[WARNING] Thất bại sau {attempt} lần gọi API. Đọc fallback từ Redis.")
    # Trả về cache cũ hoặc dữ liệu an toàn
    return {"USD": 25400, "EUR": 27100, "is_stale": True}

@retry(
    stop=stop_after_attempt(3),
    wait=wait_fixed(1),
    retry_error_callback=get_cached_rates
)
def fetch_live_exchange_rates():
    # Giả lập lỗi API tỷ giá sập hoàn toàn
    raise ConnectionResetError("Gateway Timeout")

rates = fetch_live_exchange_rates()
print("Dữ liệu sử dụng:", rates)

Lời kết

Lỗi mạng là điều không thể tránh khỏi trong môi trường cloud. Thay vì viết những vòng lặp try-except rối rắm và khó kiểm soát, Tenacity giúp codebase của bạn gọn gàng, rõ ràng và chống chịu lỗi tốt hơn nhiều. Chỉ cần gắn đúng decorator, thiết lập exponential backoff kèm jitter và lọc đúng loại exception, bạn đã giảm được 90% các sự cố gọi thức giấc lúc nửa đêm.

Share: