خانه/مقالات/اسکریپینگ با Requests: الگوهای Retry در پایتون
برنامه نویسی
استخراج داده
برگشت به مقاله‌ها

اسکریپینگ با Requests: الگوهای Retry در پایتون

اسکریپینگ با Requests: الگوهای Retry در پایتون
راهنمای عملی برای مدیریت retry در اسکریپینگ با Requests: چه کدهایی را دوباره بزنیم، استفاده از urllib3 Retry با HTTPAdapter، بهره‌گیری از tenacity برای منطق پیشرفته (هم در sync و هم async)، رعایت هدر Retry-After، و نکات مهم مربوط به idempotency و POSTها.
آسان اسکریپ آسان اسکریپ
1405-05-21

مقدمه

در این مقاله به صورت عملی یاد می‌گیرید چطور با Requests در پایتون درخواست‌های ناموفق را دوباره امتحان کنید تا ربات یا اسکریپر شما پایدارتر شود. پوشش می‌دهیم: انتخاب وضعیت‌های مناسب برای retry، استفاده از urllib3.util.Retry با HTTPAdapter، استفاده از کتابخانهٔ انعطاف‌پذیر tenacity (هم برای sync و هم async)، پیاده‌سازی سادهٔ wrapper سفارشی، نحوهٔ رعایت هدر Retry-After و نکات مربوط به ایدمپوتنسی و POSTها. در پایان چند الگوی آماده و توصیه‌های عملی خواهید داشت.

کدام کد وضعیت را باید دوباره درخواست کنیم؟

قانون ساده: فقط خطاهایی که احتمالاً گذرا هستند را دوباره بزنید. از retry برای مواردی که نیاز به رفع کدنویسی دارند یا پاسخ صریحِ بلاک‌شدن است، استفاده نکنید.

  • قابل retry: 408 (Request Timeout)، 425 (Too Early)، 429 (Too Many Requests)، و محدودهٔ 5xx (500, 502, 503, 504). همچنین خطاهای اتصال مثل ConnectionError و Timeout.
  • بدون retry: 400, 401, 403, 404 — این‌ها معمولاً نیاز به اصلاح درخواست یا حل آنتی-بات دارند.

قاعدهٔ ساده: فقط روی خطاهای اتصال، 408، 425، 429 و 5xx retry کنید — بقیه را نه.

Retry با Session و HTTPAdapter (urllib3 Retry)

این روش با افزونهٔ اضافی نیاز ندارد و اگر می‌خواهید یک سیاست پیش‌فرض روی همهٔ درخواست‌ها اعمال شود، مناسب‌ترین نقطهٔ شروع است. ایده کلی:

  1. یک شیٔ Retry بسازید و پارامترها را تنظیم کنید (تعداد کل retry، status_forcelist، متدهای مجاز).
  2. این را در HTTPAdapter بگذارید و روی Session mount کنید.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

s = requests.Session()
retries = Retry(
    total=5,
    backoff_factor=1,
    status_forcelist=[408, 429, 500, 502, 503, 504],
    allowed_methods=["HEAD", "GET", "OPTIONS"],
    respect_retry_after_header=True,
)
s.mount("http://", HTTPAdapter(max_retries=retries))
s.mount("https://", HTTPAdapter(max_retries=retries))

response = s.get("http://quotes.toscrape.com/")

توضیح ورودی/خروجی و نقش توابع:

  • requests.Session(): کانالی است که می‌توان adapter روی آن نصب کرد تا هر درخواست از آن پیروی کند.
  • Retry(...): شیٔ سیاست retry را تعریف می‌کند. ورودی‌ها مثل total (حداکثر تلاش‌ها)، status_forcelist (کدهای HTTP که باعث retry می‌شوند)، allowed_methods (فقط متدهای idempotent) و respect_retry_after_header اهمیت دارند.
  • s.mount(...): adapter را برای پروتکل‌های http/https متصل می‌کند.

خط‌به‌خط: ابتدا session ساخته می‌شود؛ سپس سیاست retry ساخته و به adapter بسته می‌شود؛ adapter روی session نصب شده و هر s.get از این سیاست پیروی می‌کند.

Exponential backoff و jitter

برای جلوگیری از حملهٔ همزمانی (thundering herd) از backoff نمایی همراه با jitter استفاده کنید. فرمول urllib3 برای تاخیر بین تلاش‌ها:

{backoff_factor} * (2 ** ({number_retries} - 1))

مثال دنباله‌ها برای backoff_factorهای مختلف:

# backoff_factor = 1
0.5, 1, 2, 4, 8, ...

# backoff_factor = 2
1, 2, 4, 8, ...

# backoff_factor = 3
5, 10, 20, ...

در urllib3 نسخهٔ 2.0 پارامتر backoff_jitter اضافه شد که تا X ثانیه jitter تصادفی به تاخیر می‌افزاید. برای اسکریپینگ با همزمانی بالا توصیه می‌شود jitter فعال باشد.

استفاده از Tenacity (انعطاف‌پذیرترین گزینه)

Tenacity به شما امکان می‌دهد بر اساس استثنا، وضعیت، یا حتی محتوای بدنهٔ پاسخ retry کنید. نصب:

pip install tenacity

import requests
from tenacity import retry, stop_after_attempt, wait_exponential_jitter,
                       retry_if_exception_type, retry_if_result

RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}

def _is_retryable_response(response):
    return response is not None and response.status_code in RETRYABLE_STATUS

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential_jitter(initial=1, max=60, jitter=2),
    retry=(retry_if_exception_type(requests.exceptions.RequestException)
           | retry_if_result(_is_retryable_response)),
    reraise=True,
)
def fetch(url, **kwargs):
    response = requests.get(url, timeout=30, **kwargs)
    return response

response = fetch("http://quotes.toscrape.com/")
print(response.status_code)

توضیح:

  • @retry: دکوراتوری که رفتار retry را ظرف تابع تعریف می‌کند.
  • stop_after_attempt(5): حداکثر 5 تلاش.
  • wait_exponential_jitter: backoff نمایی همراه با jitter.
  • شرط retry ترکیبی است: هم برای استثناهای شبکه و هم برای نتیجه (status code) بررسی می‌شود.

مزیت tenacity: می‌توانید Predicateهای دلخواه روی response.text بنویسید تا مثلاً صفحات چالش یا soft-ban را تشخیص و retry کنید (حتی اگر status کد 200 باشد).

تشخیص صفحهٔ مسدودسازی (مثال با tenacity)

from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_result

def _looks_like_ban_page(response):
    if response is None:
        return False
    if response.status_code != 200:
        return response.status_code in {408, 425, 429, 500, 502, 503, 504}
    # نمونه رشته‌هایی که در صفحات challenge دیده می‌شوند
    return ("Robot or human?" in response.text or "Just a moment" in response.text)

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential_jitter(initial=1, max=30),
    retry=retry_if_result(_looks_like_ban_page),
    reraise=True,
)
def fetch(url):
    return requests.get(url, timeout=30)

در اینجا تابع predicate بدنهٔ HTML را هم بررسی می‌کند. ورودی این predicate یک requests.Response است و خروجیِ بولی مشخص می‌کند که آیا باید retry شود یا خیر.

احترام به هدر Retry-After

وقتی سرور 429 یا 503 می‌دهد، معمولاً هدر Retry-After ارسال می‌شود. نادیده گرفتن آن منجر به تشدید throttle می‌شود. اگر از urllib3 استفاده می‌کنید، پارامتر respect_retry_after_header=True را ست کنید. در حالت دستی باید هدر را بخوانید و آن را پردازش کنید:

import time
import requests
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone

def _retry_after_seconds(response, default=5, max_wait=300):
    header = response.headers.get("Retry-After")
    if not header:
        return default
    if header.isdigit():
        return min(int(header), max_wait)
    try:
        when = parsedate_to_datetime(header)
        secs = max(0, (when - datetime.now(timezone.utc)).total_seconds())
        return min(int(secs), max_wait)
    except (TypeError, ValueError):
        return default

response = requests.get("http://quotes.toscrape.com/")
if response.status_code in (429, 503):
    wait = _retry_after_seconds(response)
    time.sleep(wait)
    response = requests.get("http://quotes.toscrape.com/")

نکتهٔ امنیتی/عملی: همیشه حداکثر تاخیر معقول (مثلاً 300 ثانیه) تعیین کنید تا سرور بدپیکربندی یا هدر اشتباه، کراولر را معطل نکند.

اسکریپینگ غیرهمزمان با httpx

برای کد async، ترکیب tenacity و httpx.AsyncClient ساده و یکنواخت است:

import asyncio
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential_jitter(initial=1, max=30),
    retry=retry_if_exception_type((httpx.TransportError, httpx.HTTPStatusError)),
    reraise=True,
)
async def fetch(client, url):
    response = await client.get(url, timeout=30)
    response.raise_for_status()
    return response

async def main():
    async with httpx.AsyncClient() as client:
        response = await fetch(client, "http://quotes.toscrape.com/")
        print(response.status_code)

asyncio.run(main())

توضیح: response.raise_for_status() خطاهایی را به صورت HTTPStatusError بالا می‌آورد که توسط tenacity قابل捕捉 است. اگر می‌خواهید فقط روی کدهای معین retry کنید، از predicate یا بررسی response.status_code استفاده کنید.

نوشتن Wrapper اختصاصی

گاهی نوشتن یک حلقهٔ ساده بهتر از وارد کردن وابستگی جدید است، مخصوصاً اگر منطق خیلی ساده‌ای دارید یا می‌خواهید دقیقاً چه چیزی retry شود را کنترل کنید:

import requests

NUM_RETRIES = 3

for _ in range(NUM_RETRIES):
    try:
        response = requests.get('http://quotes.toscrape.com/')
        if response.status_code in [200, 404]:
            # پاسخ موفق یا قابل قبول — از حلقه خارج می‌شویم
            break
    except requests.exceptions.ConnectionError:
        # تلاش بعدی انجام می‌شود
        pass

if response is not None and response.status_code == 200:
    # پردازش پاسخ
    pass

می‌توانید این منطق را در یک تابع مثل request_retry بسته‌بندی کنید تا قابل استفاده مجدد شود. مزیت: کنترل کامل روی شروط، توانایی بررسی بدنه (مانند تشخیص صفحهٔ بن) و پیاده‌سازی backoff دلخواه. عیب: باید مجدداً مواردی مثل jitter، حداکثر انتظار و احترام به Retry-After را خودتان پیاده کنید.

ایدمپوتنسی — چه زمانی نباید retry کنیم

برخی متدها idempotent هستند و برخی نه. اگر درخواست شما اثر جانبی دارد (ایجاد رکورد، پرداخت، ارسال پیام)، retry می‌تواند باعث تکرار عملیات و نتایج نادرست شود.

  • معمولاً ایمن برای retry: GET, HEAD, OPTIONS, PUT, DELETE.
  • محتاط باشید یا از retry اجتناب کنید: POST, PATCH — مگر اینکه تنها روی خطاهای اتصال retry کنید یا سرور Idempotency-Key پشتیبانی کند.

الگوی ایمن برای POST (فقط در صورت خطاهای شبکه):

import requests
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential_jitter(initial=1, max=30),
    # فقط روی خطاهای حمل و نقل retry شود — نه روی پاسخ 5xx که ممکن است سرور درخواست را پردازش کرده باشد
    retry=retry_if_exception_type(requests.exceptions.ConnectionError),
    reraise=True,
)
def post_form(url, data):
    return requests.post(url, data=data, timeout=30)

اگر سرویس شما Idempotency-Key می‌پذیرد، یک UUID ثابت برای هر درخواست منطقی ارسال کنید تا سرور بتواند تکرارها را حذف کند.

سوالات متداول (خلاصه)

  • چطور Requests به‌صورت خودکار retry می‌کند؟ به‌صورت پیش‌فرض انجام نمی‌دهد؛ باید خودتان Retry یا wrapper/tenacity را اضافه کنید.
  • چه کدهایی را retry کنیم؟ 408، 425، 429، 5xx و خطاهای اتصال؛ نه 4xx معمولی مثل 400/401/403/404.
  • backoff با jitter چیست؟ افزایش نمایی بین تلاش‌ها به‌اضافهٔ یک مقدار تصادفی (jitter) برای جلوگیری از همزمانی مشتری‌ها.
  • tenacity بهتر است یا urllib3 Retry؟ اگر می‌خواهید سیاست سراسری روی Session باشد از urllib3 Retry استفاده کنید؛ برای منطق پیشرفته یا async از tenacity استفاده کنید.

جمع‌بندی

برای اسکریپینگ با Requests، معمولاً چنین سلسله‌مراتبی را پیشنهاد می‌کنم:

  1. اگر نیاز به سیاست سراسری و ساده دارید: urllib3.util.Retry را روی یک Session با backoff و respect_retry_after_header نصب کنید.
  2. اگر نیاز به منطق پیشرفته، بررسی بدنهٔ HTML یا async دارید: از tenacity استفاده کنید.
  3. برای عملیات دارای side-effect (POST/PATCH) فقط روی خطاهای شبکه retry کنید یا از Idempotency-Key استفاده کنید.

همیشه از exponential backoff با jitter استفاده کنید، Retry-After را رعایت کنید و لیست کدهای قابل retry را محدود نگه دارید تا هم مؤثر و هم اخلاقی در اسکریپینگ عمل کنید.

مطالب مرتبط

مقاله‌های مرتبط