Logique de Retry pour Scrapers : 6 Erreurs qui Transforment les Échecs en Blocages
Quelques centaines de pages après le début d’un run, votre scraper commence à recevoir des 429 Too Many Requests. Le wrapper de retry renvoie ces requêtes. Peu après, la plupart des réponses sont des 403 ou des pages de challenge.
Votre parseur n’a pas changé. Votre trafic, si : les retries ont ajouté des requêtes au moment précis où le site commençait à les refuser. Une stratégie de retry qui traite chaque échec comme un problème serveur temporaire peut aggraver une limite de débit ou un blocage anti-bot.
La solution commence par la classification de la réponse. Ensuite, vous décidez d’attendre, de changer l’identité du client ou d’arrêter. Ces six erreurs sabotent cette décision.
1. Réessayer Immédiatement
Voici la boucle de retry par laquelle commencent la plupart des scrapers :
for attempt in range(3):
response = session.get(url)
if response.status_code == 200:
break
Une requête refusée devient trois requêtes en rafale. Si la réponse était une limite de débit, la boucle augmente le trafic au pire moment.
Les valeurs par défaut des bibliothèques n’ajoutent pas forcément de délai :
urllib3.util.Retry, qui gère les retries des adaptateursrequests, utilise par défautbackoff_factor=0. Il n’ajoute aucun backoff tant que vous n’en configurez pas.- Le
RetryMiddlewarede Scrapy réessaie408,429,500,502,503,504,522et524jusqu’àRETRY_TIMES = 2fois de plus. Il n’a aucun réglage de délai entre les retries. Avec la valeur par défautRETRY_PRIORITY_ADJUST = -1, le retry repasse dans le scheduler avec une priorité plus basse et peut partir dès qu’un slot de téléchargement se libère.
Utilisez un backoff exponentiel avec jitter. Le backoff allonge l’attente après des échecs répétés. Le jitter la rend aléatoire, pour que des workers qui échouent ensemble ne réessaient pas tous ensemble.
L’approche « full jitter » décrite dans l’article Exponential Backoff And Jitter de l’AWS Architecture Blog choisit une attente aléatoire entre zéro et min(cap, base * 2 ** attempt).
Cette configuration utilise requests avec urllib3 2.x pour ajouter backoff et jitter au niveau de l’adaptateur HTTP. Ne l’utilisez que si cette couche est la propriétaire de vos retries.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
retry = Retry(
total=3,
backoff_factor=1, # 1 s, 2 s, 4 s ... capped by backoff_max (120 s)
backoff_jitter=1.0, # adds 0-1 s of random spread
status_forcelist=[429, 500, 502, 503, 504],
respect_retry_after_header=True,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
Le backoff_jitter d’urllib3 ajoute une quantité aléatoire au backoff calculé. C’est un jitter additif, pas un full jitter : les workers partagent toujours la même courbe de backoff. Avec tenacity, wait_random_exponential(multiplier=1, max=60) donne directement du full jitter.
Quand les retries sont épuisés sur un statut de status_forcelist, requests lève requests.exceptions.RetryError au lieu de renvoyer la dernière réponse. Les couches supérieures doivent enregistrer cet échec, pas lancer une autre boucle de retry.
2. Ignorer Retry-After
Avant de calculer votre propre délai, regardez Retry-After. Un 429 ou un 503 peut s’en servir pour vous dire quand réessayer.
D’après MDN, l’en-tête contient soit un délai en secondes, comme Retry-After: 120, soit une date HTTP. Votre code doit gérer les deux formes.
Si le serveur demande 120 secondes et que votre backoff en choisit deux, vous êtes encore dans la fenêtre. Certains limiteurs de débit comptent aussi les requêtes refusées, donc des tentatives trop précoces peuvent vous y maintenir.
Le comportement varie selon la bibliothèque :
- urllib3 :
respect_retry_after_headerest activé par défaut. Son traitement spécial couvre413,429et503; ces réponses peuvent être réessayées avecRetry-Aftermême sans figurer dansstatus_forcelist, si les autres conditions de retry le permettent. Les versions qui exposentretry_after_maxplafonnent cette attente à six heures par défaut. - tenacity : il n’interprète pas les réponses HTTP. Votre fonction d’attente doit lire et parser l’en-tête elle-même.
- httpx :
HTTPTransport(retries=3)réessaieConnectErroretConnectTimeout, pas les codes de statut HTTP. Un429revient directement à votre code.
Cette fonction d’attente pour tenacity utilise le délai du serveur quand il y en a un, et sinon se rabat sur le full jitter :
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import httpx
from tenacity import (retry, retry_if_exception_type,
stop_after_attempt, wait_random_exponential)
class Retryable(Exception):
def __init__(self, response):
super().__init__(response.status_code)
self.response = response
def retry_after_seconds(response):
value = response.headers.get("Retry-After", "").strip()
if not value:
return None
if value.isdigit():
return int(value)
try:
when = parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
fallback = wait_random_exponential(multiplier=1, max=60)
def wait_for_server(retry_state):
exc = retry_state.outcome.exception()
if isinstance(exc, Retryable):
seconds = retry_after_seconds(exc.response)
if seconds is not None:
return min(seconds, 300) + random.uniform(0, 2)
return fallback(retry_state)
@retry(
retry=retry_if_exception_type((Retryable, httpx.TransportError)),
wait=wait_for_server,
stop=stop_after_attempt(3),
reraise=True,
)
def fetch(client: httpx.Client, url: str) -> httpx.Response:
response = client.get(url)
if response.status_code in (429, 502, 503, 504):
raise Retryable(response)
response.raise_for_status()
return response
N’étendez pas cette stratégie basée sur les statuts à tous les 403. Une réponse de challenge doit être classée avant une nouvelle tentative, comme le montre l’erreur 5.
3. Réessayer des Réponses qui Ne Sont Pas des Échecs
Une réponse courte n’est pas forcément un échec. Les endpoints JSON ou AJAX paginés peuvent renvoyer une liste vide, un corps vide ou un petit fragment HTML après la dernière page. Ce fragment peut contenir un doctype et quelques éléments <li>, sans <html> ni <head> complet.
Si votre validateur considère toutes ces formes comme cassées, les retries récupéreront encore et encore la même réponse valide.
Validez par rapport à ce que l’endpoint est censé renvoyer, pas par rapport à une longueur minimale du corps. Si deux identités client différentes renvoient la même réponse courte, c’est probablement la vraie réponse. Vérifiez l’état de la pagination avant de réessayer.
L’erreur inverse consiste à accepter une page de challenge parce que son statut est 200. Une validation fondée uniquement sur le statut laisse ce HTML entrer dans vos données au lieu de déclencher la gestion des blocages.
Vérifiez à la fois le statut et le contenu que vous devez extraire. Voir HTTP 200 But Blocked: Detecting Soft Blocks pour les signes de challenge et Truncated HTML Responses pour les corps incomplets.
4. Fixer le Pays du Proxy à Chaque Tentative
Le ciblage par pays restreint les sorties proxy que votre scraper peut utiliser. Un fournisseur peut disposer d’un grand pool résidentiel au total, mais de relativement peu de sorties dans le pays choisi.
Si chaque requête et chaque retry doivent passer par ce sous-ensemble, les requêtes se concentrent sur moins d’adresses IP. Le trafic par IP augmente, et le site peut répondre par des blocages ou des CAPTCHAs à curseur. Faire tourner le proxy tout en gardant la contrainte de pays ne vous donne pas forcément un pool très différent.
Vérifiez d’abord si la contrainte de pays est nécessaire :
- Récupérez la même page via deux pays.
- Comparez les champs que votre scraper utilise réellement, comme le prix, la devise, le stock et la langue.
- Si les champs utiles sont identiques, supprimez la contrainte de pays.
- S’ils diffèrent, gardez le pays requis. Au retry, attendez et baissez la concurrence au lieu de tirer une autre sortie du même petit pool.
Un marché voisin n’est une alternative qu’après avoir vérifié qu’il sert la même version utile de la page. N’échangez pas des données correctes contre une requête plus facile.
5. Réessayer un Blocage avec la Même Identité
Un 403 seul ne dit pas pourquoi l’accès a échoué : ce peut être une connexion obligatoire, une règle du site ou une décision anti-bot. Quand Cloudflare, DataDome ou Akamai renvoient un challenge, la décision tient compte de votre adresse IP, de votre empreinte TLS, de vos en-têtes et de vos cookies de session. Répéter le même état client renvoie les mêmes signaux.
Cloudflare marque explicitement ses réponses de challenge : cf-mitigated: challenge. Vérifiez cet en-tête en plus du corps. Les autres réponses de challenge demandent leurs propres vérifications de contenu.
Utilisez la classification de la réponse pour choisir l’étape suivante :
| Réponse | Interprétation Probable | Avant de Réessayer |
|---|---|---|
429 avec Retry-After | Limite de débit | Attendre le délai indiqué et réduire la concurrence pour cet hôte |
502, 503, 504, 522 ou 524 sans marqueurs de challenge | Panne de l’origine ou de l’edge | Backoff ; garder la même identité peut convenir |
403 avec cf-mitigated: challenge ou un HTML de challenge reconnaissable | Challenge anti-bot | Nouvelle IP et cookies de session neufs, ou un autre client |
200 contenant un CAPTCHA ou un HTML « vérifiez que vous êtes humain » | Blocage discret | Le traiter comme un challenge, pas comme une page réussie |
| Connexion réinitialisée pendant le handshake TLS | Panne réseau ou possible blocage à l’edge | Autoriser un retry avec une nouvelle connexion et un nouveau proxy |
404 ou 410 | Page absente ou supprimée | Ne pas réessayer comme un échec temporaire |
Face à un blocage confirmé, montez des changements les moins chers aux plus chers : une nouvelle session proxy, puis une autre empreinte client, puis un navigateur avec Playwright. Par exemple, curl_cffi propose une cible impersonate pour choisir l’empreinte du client. Changez-la délibérément au lieu de rejouer la même configuration bloquée.
Chaque palier ajoute du temps et du coût. Ne passez au suivant que si le précédent a échoué, et gardez toute l’échelle dans un seul budget de tentatives.
Isolez aussi les URLs qui échouent à répétition. Si une URL est bloquée avec deux ou trois identités alors que d’autres pages du même hôte se chargent bien, arrêtez d’y dépenser des identités qui fonctionnent. Une règle plus stricte sur ce chemin ou un piège peut être en cause. Mettez l’URL de côté pour l’examiner.
6. Laisser Chaque Couche Réessayer
Des couches de retry indépendantes multiplient les tentatives. Elles ne se contentent pas de les additionner.
Supposons que votre client HTTP réessaie trois fois, soit quatre tentatives. Un middleware Scrapy ou votre propre wrapper ajoute deux retries, soit trois exécutions de cette opération client. Ensuite, la file de tâches relance la tâche en échec ; le autoretry_for de Celery utilise par défaut max_retries égal à trois, soit quatre exécutions de la tâche.
Le total est de 4 × 3 × 4 = 48 requêtes pour une seule URL qui échoue durablement. Quand beaucoup d’URLs échouent ensemble, toutes ces couches amplifient le trafic alors que le site refuse déjà les requêtes.
Le chapitre sur la gestion de la surcharge du livre SRE de Google décrit cette même amplification par les retries. Ses règles s’appliquent directement à la conception d’un scraper :
- Choisissez un seul propriétaire des retries. Les autres couches lui remontent les échecs au lieu de lancer leurs propres boucles.
- Plafonnez les tentatives par requête. Le livre autorise trois échecs avant de remonter l’erreur à l’appelant.
- Appliquez un budget de retries. Dans son exemple, les retries ne sont permis que tant qu’ils restent sous 10 % de toutes les requêtes.
Dans un scraper, suivez ce budget par hôte. Ajoutez un circuit breaker qui suspend temporairement les requêtes vers un hôte quand le taux de blocage dépasse votre seuil. Sans lui, même une boucle de retry bornée peut saturer un hôte quand assez de workers l’exécutent en même temps. Le budget limite le trafic supplémentaire ; le breaker met en pause un hôte qui refuse trop de requêtes :
import random
import time
from collections import defaultdict, deque
from urllib.parse import urlsplit
class HostGuard:
"""Per-host retry budget plus a circuit breaker."""
def __init__(self, retry_ratio=0.1, window=200,
block_threshold=0.3, cooldown=600):
self.retry_ratio = retry_ratio
self.block_threshold = block_threshold
self.cooldown = cooldown
self.sent = defaultdict(lambda: deque(maxlen=window)) # True = retry
self.blocked = defaultdict(lambda: deque(maxlen=window)) # True = block
self.open_until = {}
def can_send(self, host):
return time.monotonic() >= self.open_until.get(host, 0)
def can_retry(self, host):
sent = self.sent[host]
budget = self.retry_ratio * max(len(sent), 10)
return self.can_send(host) and sum(sent) < budget
def record(self, host, is_retry, blocked):
self.sent[host].append(is_retry)
recent = self.blocked[host]
recent.append(blocked)
if len(recent) >= 50 and sum(recent) / len(recent) >= self.block_threshold:
self.open_until[host] = time.monotonic() + self.cooldown
recent.clear()
def crawl(urls, fetch_once, max_attempts=3):
"""fetch_once(url, attempt) returns an object with .ok, .blocked, .retryable."""
guard = HostGuard()
parked = []
for url in urls:
host = urlsplit(url).hostname
if not guard.can_send(host):
parked.append(url)
continue
result = fetch_once(url, attempt=0)
guard.record(host, is_retry=False, blocked=result.blocked)
attempt = 1
while (not result.ok and result.retryable
and attempt < max_attempts and guard.can_retry(host)):
time.sleep(random.uniform(0, min(60, 2 ** attempt)))
result = fetch_once(url, attempt=attempt)
guard.record(host, is_retry=True, blocked=result.blocked)
attempt += 1
if not result.ok:
parked.append(url)
return parked
fetch_once doit effectuer la classification des réponses des erreurs 3 et 5. Si une nouvelle tentative est permise après un blocage, il doit préparer le changement d’identité avant qu’elle ne parte.
La boucle d’exemple est séquentielle. Avec asyncio ou un pool de threads, partagez un seul HostGuard entre les workers qui ciblent le même hôte. Des guards séparés donneraient à chaque worker son propre budget et masqueraient le taux d’échec global.
Le compromis : ces contrôles laissent volontairement certaines URLs inachevées. Vous renoncez à un run complet pour ne pas perdre l’hôte pendant les dix suivants. Mettez le reste de côté pour un run ultérieur, à un rythme plus faible.
Un breaker ne sait pas non plus distinguer une panne du site d’un blocage de votre identité ; les deux le déclenchent. Examinez des réponses enregistrées avant de décider s’il faut changer de client ou simplement attendre.
FAQ
Combien de fois un scraper doit-il réessayer une requête en échec ?
Deux ou trois tentatives au total par URL, gérées par une seule couche, suffisent pour les échecs temporaires comme les timeouts et les réponses 503. Si elle échoue encore, arrêtez la boucle en cours, mettez l’URL de côté et réessayez-la lors d’un run ultérieur.
Faut-il réessayer une réponse 403 Forbidden ?
Pas à l’aveugle avec la même requête. Distinguez d’abord une réponse anti-bot d’une restriction d’accès. Pour un blocage anti-bot, changez l’IP, l’empreinte ou l’état de session avant une nouvelle tentative. Changer d’identité ne remplacera pas une connexion ou une autorisation manquante.
Quelle est la différence entre backoff exponentiel et jitter ?
Le backoff exponentiel allonge l’attente entre les tentatives, par exemple 1, 2, 4 puis 8 secondes. Le jitter rend l’attente aléatoire pour que des workers qui échouent ensemble ne réessaient pas ensemble. Utilisez les deux et respectez Retry-After quand le serveur l’envoie.
Scrapy attend-il entre les retries ?
Non. RetryMiddleware replanifie la requête avec une priorité plus basse et sans délai. Activez AutoThrottle (AUTOTHROTTLE_ENABLED = True), qui ne laisse jamais une réponse non 200 réduire le délai, gardez RETRY_TIMES bas et écrivez votre propre middleware si vous avez besoin d’un vrai backoff par requête.
Comptez d’Abord Vos Couches de Retry
Ouvrez votre scraper et listez chaque endroit qui réessaie : l’adaptateur HTTP, les réglages Scrapy, votre wrapper, la file de tâches et toute relance planifiée des lots en échec. Multipliez les tentatives. Si le résultat dépasse trois, choisissez un seul propriétaire et désactivez les autres boucles de retry avant de toucher aux délais.
Si vous préférez ne pas maintenir vous-même les changements de proxy, d’empreinte et de navigateur entre les tentatives, ScrapeUnblocker gère les proxies, le rendu et les blocages de son côté. Sa documentation des erreurs indique qu’un blocage 403, un timeout ou un 429 ne sont pas facturés. Votre stratégie de retry reste ainsi à un seul endroit, autour de cet appel d’API.
Essayez ScrapeUnblocker gratuitement
Taux de réussite de plus de 95 % · à partir de 0,55 € pour 1 000 appels (TVA éventuelle en sus) · 500 requêtes gratuites à l'inscription.