איפה 90% מה-scrapers נופלים
ראיתי את זה מאות פעמים. מהנדס צעיר, או אפילו מנוסה, מקבל משימת scraping. הוא כותב לוגיקה שמטפלת ב-happy path, ואז מוסיף try/except עם לולאת while פשוטה לטיפול בשגיאות. משהו כזה:
retries = 0
while retries < 5:
try:
response = requests.get(url)
response.raise_for_status()
break # הצלחה, יוצאים מהלולאה
except requests.exceptions.RequestException:
retries += 1
time.sleep(2) # נחכה 2 שניות וננסה שוב
על הנייר, זה נראה סביר. במציאות, זה מתכון לאסון. למה? כי כל ה-requests הכושלים שלך ינסו שוב בדיוק באותו זמן, כל 2 שניות. אם יש לך מאות או אלפי תהליכים מקביליים, יצרת הרגע התקפת DDoS עצמית על השרת המטרה (ושרתי הפרוקסי שלך). קוראים לזה "בעיית העדר הרועם" (Thundering Herd Problem). כל ה-retries קורים בבת אחת, מה שמבטיח שהשרת יישאר עמוס וימשיך להחזיר שגיאות.
הגישה הזאת לא רק לא יעילה, היא גם שורפת לך כסף על פרוקסיז וגורמת לחסימות מהירות יותר. ב-web scraping בסקייל, ה-retry logic הוא לא פיצ'ר. הוא הליבה של המערכת.
היסודות: Exponential Backoff
הצעד הראשון מעבר ללוגיקה נאיבית הוא exponential backoff. הרעיון פשוט: במקום לחכות זמן קבוע בין ניסיונות, אנחנו מכפילים את זמן ההמתנה אחרי כל כישלון. זה נותן לשרת המטרה "מרחב נשימה" להתאושש.
הנוסחה הבסיסית היא delay = base_delay * (2 ** attempt_number). אם זמן ההמתנה הבסיסי הוא שנייה אחת, ההמתנות ייראו כך:
- ניסיון 1 נכשל: המתנה של 1 שנייה (1 * 2^0)
- ניסיון 2 נכשל: המתנה של 2 שניות (1 * 2^1)
- ניסיון 3 נכשל: המתנה של 4 שניות (1 * 2^2)
- ניסיון 4 נכשל: המתנה של 8 שניות (1 * 2^3)
זה כבר שיפור עצום. זה מרווח את הבקשות ומפחית את הסיכוי ליצור עומס מתמשך. אבל זה עדיין לא מספיק. אם כל ה-workers שלך מתחילים באותו זמן וחווים כישלון, הם עדיין ינסו שוב בסינכרון מושלם, רק במרווחים גדלים והולכים. אנחנו חייבים לשבור את הסימטריה.
השדרוג הקריטי: Jitter
Jitter הוא המרכיב הסודי שהופך backoff פשוט למנגנון חסין באמת. Jitter מוסיף אקראיות לזמן ההמתנה, ומונע מה-workers שלך לפעול במקצבים מסונכרנים. יש שתי גישות עיקריות:
Full Jitter
הגישה הפשוטה והיעילה ביותר. במקום לחכות בדיוק את הזמן שחושב על ידי ה-backoff, אנחנו בוחרים זמן אקראי בין 0 לזמן הזה.
sleep_time = random.uniform(0, base_delay * (2 ** attempt_number))
לדוגמה, אחרי הכישלון השלישי (שבו ה-backoff הוא 4 שניות), ה-worker יחכה זמן אקראי כלשהו בין 0 ל-4 שניות. זה מפרק את הסינכרון לחלוטין והוא שיפור אדיר. במערכות רבות, זה כל מה שצריך.
Decorrelated Jitter
זו גישה מתקדמת יותר שנותנת תוצאות מעולות. היא מגבילה את הגידול האקספוננציאלי כדי למנוע זמני המתנה ארוכים מדי, ועדיין מספקת פיזור מעולה. הנוסחה קצת יותר מורכבת:
sleep_time = base_delay * random.uniform(1, (last_sleep_time / base_delay) * 3)
הרעיון הוא שזמן ההמתנה הבא תלוי בזמן ההמתנה הקודם, עם מכפיל אקראי. זה יוצר פיזור סטטיסטי טוב יותר ומונע מצב שבו worker אחד "נתקע" עם המתנה של 30 שניות בזמן שאחרים כבר המשיכו הלאה. למשימות scraping קריטיות שרצות בסקייל גבוה, זו הגישה המועדפת עליי.
לא כל שגיאה נולדה שווה: Max Retries דינמי
אחד הכשלים הגדולים ביותר הוא להתייחס לכל השגיאות באותו אופן. לנסות 5 פעמים בקשה שמחזירה 404 (Not Found) זה בזבוז זמן ומשאבים. הדף כנראה לא קיים. לעומת זאת, שגיאת 503 (Service Unavailable) היא לרוב זמנית ודורשת ניסיונות חוזרים אגרסיביים יותר.
אסטרטגיה חכמה ממפה סוגי שגיאות למספר ניסיונות וזמני המתנה שונים:
- שגיאות 5xx (500, 502, 503, 504): אלו שגיאות שרת. הן כמעט תמיד זמניות. כאן נרצה 5-8 ניסיונות עם exponential backoff ו-jitter. השרת כנראה תחת עומס או בתהליך deploy, והוא יתאושש.
- שגיאות 429 (Too Many Requests): זו שגיאה קריטית שאומרת "אתה מהיר מדי". כאן ה-retry logic חייב להיות חכם במיוחד. אם התשובה כוללת header של
Retry-After, אנחנו חייבים לכבד אותו. אם לא, נשתמש ב-backoff ארוך משמעותית. לטפל בזה לא נכון זו הדרך המהירה ביותר לחסום IP. כתבנו על זה מדריך שלם על התמודדות עם שגיאות 429 ו-rate limiting. - שגיאות 404 (Not Found): לרוב אין טעם לנסות שוב. אולי ניסיון אחד נוסף אחרי שנייה, למקרה של תקלה רגעית ברשת, ואז לוותר.
- שגיאות 403 (Forbidden): זה יכול להיות סימן לחסימת IP או User-Agent. ניסיון חוזר עם אותו הפרוקסי הוא חסר טעם. כאן ה-retry צריך להיות משולב עם החלפת פרוקסי. שימוש ב-residential proxies איכותיים יכול לעזור מאוד כאן.
הפרדה כזו הופכת את ה-scraper שלך ליעיל בהרבה. הוא לא מבזבז זמן על שגיאות קבועות, ונותן צ'אנס אמיתי להתאושש משגיאות זמניות.
דוגמת קוד רצינית עם Tenacity ו-Asyncio
לכתוב את כל הלוגיקה הזאת מאפס זה אפשרי, אבל מיותר. בסביבת Python, ספריית tenacity היא הכלי המושלם למשימה. היא מטפלת בכל המורכבות של backoff, jitter, וקביעת תנאי עצירה.
הנה דוגמה לאיך בונים retry logic חכם עם tenacity בסביבת asyncio, שמתאימה ל-scraping מודרני:
import asyncio
import random
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type
import httpx
# שגיאות שאנחנו רוצים לנסות שוב (שגיאות רשת ושרת)
RETRYABLE_EXCEPTIONS = (
httpx.TimeoutException,
httpx.ConnectError,
httpx.NetworkError,
httpx.ServerNotAvailable,
httpx.RemoteProtocolError,
)
# פונקציה שמחליטה אם לנסות שוב על סמך סטטוס קוד
def should_retry_response(response: httpx.Response) -> bool:
# נסה שוב על שגיאות שרת או rate limiting
return response.status_code in [429, 500, 502, 503, 504]
# פונקציית ה-retry העיקרית
@retry(
stop=stop_after_attempt(7),
wait=wait_random_exponential(multiplier=1, max=60), # Exponential backoff + full jitter
retry=(retry_if_exception_type(RETRYABLE_EXCEPTIONS) | (lambda r: should_retry_response(r.result())))
)
async def fetch_url(client: httpx.AsyncClient, url: str) -> httpx.Response:
print(f"Fetching {url}...")
response = await client.get(url, timeout=15)
# אם זו לא שגיאה שגורמת ל-exception, אבל עדיין רוצים retry (למשל 503)
if should_retry_response(response):
# tenacity תופס את זה ומפעיל את ה-retry
raise httpx.HTTPStatusError(f"Bad status: {response.status_code}", request=response.request, response=response)
# אם הסטטוס קוד תקין, או שזו שגיאה שלא מצריכה retry (כמו 404)
response.raise_for_status() # יזרוק exception על 4xx/5xx שלא טיפלנו בהם
return response
async def main():
async with httpx.AsyncClient() as client:
try:
# נדמה כתובת שלא עובדת
result = await fetch_url(client, "https://httpstat.us/503")
print(f"Success! Status: {result.status_code}")
except Exception as e:
print(f"Failed after all retries: {e}")
if __name__ == "__main__":
asyncio.run(main())
בדוגמה הזאת, אנחנו משתמשים ב-wait_random_exponential כדי לקבל exponential backoff עם full jitter. אנחנו מגדירים תנאי retry מורכב: נסה שוב אם נזרק exception מסוג מסוים או אם התשובה חזרה עם סטטוס קוד בעייתי. זו תבנית חזקה מאוד שאפשר להרחיב בקלות.
מתי כל זה נכשל? ה-Retry Budget הגלובלי
גם האסטרטגיה החכמה ביותר יכולה להיכשל. דמיין תרחיש: אתר המטרה נופל לגמרי. לא מחזיר 503, פשוט לא עונה. כל 10,000 ה-workers שלך נכנסים למצב retry. הם יחכו שנייה, שתיים, ארבע, שמונה... הם תופסים משאבים, מחזיקים חיבורים פתוחים, ומחכים. המערכת שלך עלולה להגיע למצב של "כשל מדורג" (cascading failure), שבו תקיעת ה-workers גורמת למערכות אחרות (כמו בסיס הנתונים או תור ההודעות) לקרוס.
הפתרון הוא Retry Budget גלובלי, או מנגנון Circuit Breaker. הרעיון הוא להפסיק לנסות לגשת ליעד מסוים אם אחוז הכישלונות עובר סף מסוים בפרק זמן קצוב. למשל: "אם יותר מ-30% מהבקשות לדומיין X נכשלות ב-5 הדקות האחרונות, הפסק לשלוח בקשות לדומיין הזה למשך 10 דקות".
זה מונע מה-scraper שלך להמשיך להטיח את הראש בקיר, חוסך משאבים, ונותן למערכת המטרה (ולמערכת שלך) זמן להתאושש. זהו נושא מורכב שנוגע לליבת ה-ארכיטקטורה של מערכות scraping גדולות.
להתמודד עם כישלון סופי: Dead Letter Queues
אחרי 7 ניסיונות, הבקשה עדיין נכשלה. מה עכשיו? לזרוק אותה לפח זה הפתרון הקל, אבל הוא גורם לאיבוד מידע. הפתרון המקצועי הוא לשלוח את הבקשה הכושלת (יחד עם סיבת הכישלון האחרון) ל-"תור מכתבים מתים" (Dead Letter Queue - DLQ).
ה-DLQ הוא פשוט תור נפרד שאוסף את כל המשימות שנכשלו סופית. זה מאפשר לנו: 1. ניתוח: לבדוק מדוע בקשות נכשלות. אולי יש תבנית חדשה באתר? אולי טווח IP שלם נחסם? 2. ניסיון חוזר ידני: להריץ מחדש את כל הבקשות הכושלות אחרי כמה שעות, או אחרי שתיקנו את הבעיה. 3. התראות: להפעיל התראה אם ה-DLQ מתחיל להתמלא במהירות, מה שמעיד על בעיה מערכתית.
בניית מערכת עם DLQ מבדילה בין scraper חובבני למערכת איסוף נתונים אמינה שיכולה לרוץ 24/7 בלי השגחה מתמדת. זה השלב האחרון בהפיכת ה-retry logic שלך ממנגנון פסיבי לכלי אקטיבי לניטור ותחזוקת המערכת.
שאלות נפוצות
ההבדל המרכזי הוא באופן הפיזור וזמן ההמתנה המקסימלי. Full Jitter בוחר זמן המתנה אקראי בין 0 לערך ה-backoff המחושב, מה שמפזר בקשות בצורה יעילה אך יכול ליצור המתנות ארוכות מאוד. Decorrelated Jitter מגביל את הגידול האקספוננציאלי ומשתמש בזמן ההמתנה הקודם כדי לחשב את הבא, מה שיוצר פיזור סטטיסטי טוב יותר ומונע זמני המתנה קיצוניים. עבור רוב המקרים, Full Jitter מספיק, אך במערכות עם עשרות אלפי workers, Decorrelated Jitter יכול למנוע צווארי בקבוק.
ב-Tenacity ניתן להגדיר פונקציית wait מותאמת אישית. במקום להשתמש ב-wait_exponential, כותבים פונקציה שבודקת את התוצאה של הניסיון הכושל. אם התוצאה היא response עם סטטוס 429 ו-header בשם 'Retry-After', הפונקציה תקרא את הערך (שהוא בדרך כלל בשניות) ותחזיר אותו כזמן ההמתנה. אם ה-header לא קיים, הפונקציה יכולה לחזור להשתמש בלוגיקת exponential backoff רגילה. זה מאפשר ל-scraper שלך להיות "אזרח טוב" ולציית לבקשות השרת.
Retry Budget הוא מנגנון הגנה שמונע ממערכת להמשיך לנסות פעולה שכנראה תיכשל, ובכך לבזבז משאבים. מיישמים אותו בדרך כלל עם מונה מרכזי (למשל ב-Redis) שעוקב אחר יחס ההצלחות והכישלונות ליעד מסוים. לדוגמה, אם ב-60 השניות האחרונות היו 100 בקשות ו-40 נכשלו (40% כישלון), המערכת יכולה להחליט 'לפתוח את המפסק' (open the circuit breaker) ולהפסיק לשלוח בקשות לאותו יעד למשך 5 דקות. זה מונע כשל מדורג ושומר על יציבות המערכת.
בהחלט, זה קריטי. לוגיקת retry חכמה צריכה להבין <i>למה</i> הבקשה נכשלה. אם הכישלון הוא שגיאת רשת זמנית או שגיאת 503, הגיוני לנסות שוב עם אותו הפרוקסי. אבל אם הכישלון הוא שגיאת 403, 407, או CAPTCHA, ניסיון חוזר עם אותו הפרוקסי הוא כמעט תמיד חסר טעם. במקרים אלו, פעולת ה-retry צריכה לכלול גם החלפה של הפרוקסי או אפילו את כל ה-fingerprint (כולל User-Agent ו-headers) לפני הניסיון הבא.
לא כדאי להשתמש ב-retry logic אגרסיבי כאשר הפעולה אינה אידמפוטנטית, כלומר, ביצוע שלה יותר מפעם אחת משנה את התוצאה. לדוגמה, שליחת טופס הזמנה, ביצוע תשלום, או לחיצה על כפתור 'הוסף לסל'. במקרים כאלה, כישלון רשת יכול להוביל למצב לא ברור (האם הפעולה הצליחה בצד השרת?). ניסיון חוזר אוטומטי עלול לגרום להזמנות כפולות. במצבים אלו, עדיף שהפעולה תיכשל, תירשם ללוג, ותטופל באופן ידני או עם לוגיקה עסקית מורכבת יותר.
