Scraping

Logique de nouvelle tentative pour proxy résidentiel : gérer les requêtes échouées et limitées en débit

Une nouvelle tentative est une décision, pas une boucle. Voici le tableau de classification, le code et les garde-fous qui empêchent les nouvelles tentatives de transformer une mauvaise minute en une mauvaise journée.

Chris Collins

Chris Collins

28 août 2026 · 8 min de lecture

Chaque scraper a besoin d’une logique de retry, et la plupart d’entre eux la développent accidentellement : un bloc try ici, un time.sleep là, un compteur de tentatives ajouté la semaine où quelque chose s’est cassé. Le résultat fonctionne jusqu’au jour où une cible traverse une mauvaise heure, moment auquel le chemin de retry cause plus de dégâts que l’échec initial. L’argumentaire expliquant pourquoi cela arrive se trouve dans retry et backoff ; ceci en est l’implémentation.

L’idée organisatrice est qu’un retry est une décision prise à partir d’un échec classifié, et non une boucle enroulée autour d’une requête. Obtenez la bonne classification et le reste suit naturellement.

Classifier d’abord

Chaque échec appartient à l’une de quatre classes, et chacune a exactement une réponse correcte.

ClasseExemplesRéponse
Transitoiretimeout, connexion réinitialisée, 502, 503, 504retry avec backoff
Débit429, Retry-After présentattendre comme indiqué, même route
Identitépage de challenge, 403 persistant, page de blocagenouvelle session, puis retry
Terminale404, 400, 401, 407, échec de parsingne pas retenter, signaler

Deux d’entre elles sont celles que l’on gère mal. Les signaux de débit sont des instructions sur le rythme, donc la réponse consiste à attendre plutôt qu’à changer d’adresse, car faire tourner les adresses pour maintenir le même rythme est exactement le comportement qui transforme un throttle en blocage. Les échecs terminaux ne doivent jamais être retentés : un 407 signifie des identifiants erronés ou un indicateur de nom d’utilisateur malformé, et sera tout aussi faux à la cinquième tentative, et un échec de parsing est un bug dans votre code qu’un retry reproduit fidèlement à l’infini.

Le cinquième cas est celui qui n’apparaît jamais dans un code de statut : une réponse qui semble correcte mais ne l’est pas. Une page de challenge, un ensemble de résultats vide, ou un listing tronqué servi avec un 200 appartient à la classe identité, mais seulement si vous le remarquez, ce qui signifie valider le corps avant de décider quoi que ce soit. Cette vérification est le fondement de tout le système, selon détecter le contenu bloqué ou factice.

Classification en code

import random, time, requests

TRANSIENT = {502, 503, 504}

def classify(resp, exc, is_valid):
    if exc is not None:
        return "transient"                      # timeout, reset, DNS
    if resp.status_code == 429:
        return "rate"
    if resp.status_code in TRANSIENT:
        return "transient"
    if resp.status_code in (403, 401) or looks_like_challenge(resp):
        return "identity"
    if resp.status_code == 200 and not is_valid(resp.text):
        return "identity"                       # soft block: 200 but not our data
    if resp.ok:
        return "ok"
    return "terminal"                           # 404, 400, 407, everything else

looks_like_challenge est spécifique à chaque cible et se résume généralement à une courte liste de marqueurs : une référence à un script de captcha, un titre d’interstitiel connu, un corps bien plus court qu’une vraie page. Gardez-le à un seul endroit par cible pour que le chemin de retry et votre monitoring utilisent la même définition.

Backoff avec jitter, et respect de Retry-After

Pour la classe transitoire, le délai croît de manière exponentielle et doit être randomisé. Des délais fixes synchronisent vos workers, de sorte qu’une centaine de requêtes qui échouent ensemble retentent ensemble, et la rafale survit au backoff.

def backoff(attempt, base=1.0, cap=60.0):
    ceiling = min(cap, base * (2 ** attempt))
    return random.uniform(0, ceiling)           # full jitter

Pour la classe débit, la cible peut vous dire exactement combien de temps attendre, et cette instruction prime sur votre propre calendrier :

def wait_for(resp, attempt):
    ra = resp.headers.get("Retry-After")
    if ra:
        try:
            return min(float(ra), 300)          # honour it, but cap it
        except ValueError:
            pass                                 # HTTP-date form, fall through
    return backoff(attempt, base=2.0)            # rate signals start slower

Assembler le tout

MAX_ATTEMPTS = 4

def fetch(url, country, is_valid, session_id=None):
    sid = session_id
    for attempt in range(MAX_ATTEMPTS):
        proxies = build_proxies(country, sid)     # sid=None means rotate
        resp = exc = None
        try:
            resp = requests.get(url, proxies=proxies, timeout=20)
        except requests.RequestException as e:
            exc = e

        kind = classify(resp, exc, is_valid)

        if kind == "ok":
            return resp
        if kind == "terminal":
            raise TerminalError(url, getattr(resp, "status_code", None))
        if kind == "identity":
            sid = new_session_id() if sid else None   # retire the session
            time.sleep(backoff(attempt))
        elif kind == "rate":
            time.sleep(wait_for(resp, attempt))       # wait, do not rotate
        else:
            time.sleep(backoff(attempt))

    raise Exhausted(url)

Trois détails ici comptent plus que la structure. La fonction lève une exception sur les erreurs terminales plutôt que de retourner None, de sorte qu’un appelant ne puisse pas confondre un échec avec des données vides. Les échecs d’identité remplacent la session plutôt que de la réutiliser, car la précédente est déjà connue de la cible. Et les échecs de débit ne touchent délibérément pas à la session, gardant la même route tout en ralentissant.

Des garde-fous pour empêcher les retries de devenir le problème

La logique par requête ne suffit pas à elle seule, car elle n’a aucune vue sur le système. Trois ajouts font l’essentiel du travail de protection.

Un budget de retry plafonne les retries à une part du trafic total vers une cible, disons dix pour cent. En fonctionnement normal, vous ne vous en approchez jamais. Quand une cible se casse largement, le budget est épuisé immédiatement et les retries supplémentaires n’ont simplement pas lieu, ce qui est le comportement souhaité, puisque les retries aident face à des échecs isolés et nuisent activement pendant une panne générale.

Un disjoncteur (circuit breaker) par cible arrête entièrement l’envoi une fois que le taux d’échec dépasse un seuil, patiente pendant un temps de recharge, puis laisse passer un filet de requêtes pour tester la reprise. Cela protège la cible de votre accumulation et protège vos adresses de l’accumulation d’échecs contre un site qui ne répond à personne.

Un limiteur de débit partagé que les retries traversent également. Si les retries contournent votre régulation de rythme, votre chemin d’erreur devient un flot non régulé exactement au moment où la cible est le moins capable de l’encaisser. Faites passer chaque tentative par le même limiteur, selon limitation de débit et throttling.

Ces trois éléments appartiennent au composant qui voit déjà chaque requête, ce qui est l’argument en faveur d’un gestionnaire de proxy plutôt que d’un code de retry par tâche.

Idempotence et file de messages morts

Deux préoccupations pratiques faciles à oublier.

Les retries supposent que l’opération peut être répétée sans danger. Pour la collecte, c’est presque toujours vrai, puisque récupérer une page deux fois ne coûte que de la bande passante et rien d’autre. Si une partie de votre pipeline écrit en effet secondaire lors de la récupération, rendez l’écriture idempotente, indexée sur quelque chose de stable, pour qu’un retry ne crée pas un enregistrement en double.

Et lorsqu’une requête épuise ses tentatives, ne la jetez pas. Poussez-la vers une file de messages morts (dead-letter queue) et retraitez-la bien plus tard, lors de la prochaine exécution ou après un long temps de recharge, plutôt que dans la rafale en cours. La plupart des éléments qui échouent pendant un incident réussissent d’eux-mêmes une heure plus tard, et une file transforme un échec définitif en échec différé sans coût.

Surveiller le taux de retry

Les retries sont l’avertissement le plus précoce que vous obtenez. Le ratio de retry, c’est-à-dire les retries en part des requêtes par cible, augmente avant que le taux de succès ne baisse, car un pipeline qui retente jusqu’à obtenir un résultat d’apparence normale masque un problème plutôt que de le résoudre. C’est aussi un coût pur sur un produit facturé à la bande passante, donc c’est à la fois une métrique de santé et une métrique de dépense. Placez-la sur le tableau de bord à côté du taux de succès validé, selon les KPI proxy et surveiller votre pipeline.

L’essentiel

La logique de retry est un classificateur suivi de quatre réponses, pas une boucle avec un compteur. Validez le corps pour que les blocages implicites soient classés comme des échecs, puis attendez sur les signaux de débit sans faire tourner les adresses, retirez la session sur les échecs d’identité, appliquez un backoff avec jitter sur les échecs transitoires, et ne retentez jamais une erreur terminale. Plafonnez les tentatives et le délai. Ajoutez ensuite les garde-fous qu’une vue par requête ne peut pas fournir : un budget de retry pour qu’une panne générale ne puisse pas devenir un flot, un disjoncteur par cible, et un limiteur partagé que les retries respectent également. Envoyez les requêtes épuisées vers une file de messages morts pour une exécution ultérieure, et surveillez le ratio de retry comme signal le plus précoce qu’un changement est en cours.

La moitié « failover » de tout cela, c’est-à-dire avoir un endroit propre où retenter, est ce que fournissent les proxies résidentiels : un large pool d’adresses réelles de niveau domestique afin qu’une session retirée soit remplacée plutôt que réutilisée, avec une tarification au GB qui rend les retries disciplinés directement moins coûteux que les retries indisciplinés.

Prêt à commencer ?

Essayez les proxies résidentiels de Shifter, 205M+ IPs, 195+ pays, à partir de $0.75/GB.

Commencer