La plupart des bases de code de scraping développent un gestionnaire de proxy, que quelqu’un en conçoive un ou non. Cela commence comme un helper qui construit une URL de proxy, puis quelqu’un ajoute une nouvelle tentative, puis un cas particulier pour une cible qui nécessite des sessions persistantes, puis un compteur pour arrêter de marteler une adresse qui échoue sans cesse. Dix-huit mois plus tard, cette logique est répartie sur quatre modules et personne ne peut dire ce qui se passe quand une requête échoue deux fois sur la même session.
Cela vaut la peine de le construire délibérément, car les décisions concernées sont véritablement partagées : quel exit cette requête utilise-t-elle, cet exit est-il sain, que se passe-t-il en cas d’échec, et comment en gérer le rythme. Voici une conception pour ce composant et l’état qu’il doit conserver.
De quoi le gestionnaire est responsable
Tracez la frontière avec précision. Un gestionnaire de proxy décide comment une requête sort de votre infrastructure et que faire quand cela échoue. Il n’analyse pas les pages, il ne sait pas à quoi ressemble une fiche produit, et il ne décide pas quelles URL visiter. Le garder ignorant de votre logique métier est ce qui permet à chaque job de le partager.
Cela laisse quatre responsabilités : sélectionner un exit pour chaque requête selon une politique, suivre la santé de ce qu’il distribue, appliquer la gestion des échecs quand quelque chose tourne mal, et faire respecter un rythme pour que les appelants ne puissent pas individuellement submerger une cible. Tout le reste appartient ailleurs.
Sélection : la politique est par job, pas globale
La première chose dont le gestionnaire a besoin est une notion du type d’exit qu’un appelant souhaite, plutôt qu’un unique mode global. En pratique, il en existe trois.
Rotatif, où chaque requête obtient un exit frais. C’est le mode par défaut pour la collecte en masse et il s’exprime en omettant un identifiant de session, ce qui correspond à la façon dont le modèle de rotation est conçu pour être utilisé.
Persistant (sticky), où un appelant conserve un exit pour une séquence, et le gestionnaire doit renvoyer le même identifiant de session pendant toute la durée de vie de cette séquence, puis le libérer. Les compromis sont détaillés dans sticky versus rotating.
Géo-localisé, où l’exit doit se trouver dans un pays ou une ville spécifique, ce qui, pour un travail multi-marché, est orthogonal aux deux premiers : un job peut être rotatif-en-Allemagne ou persistant-à-Chicago.
Modélisez cela comme un bail (lease) à portée de requête plutôt qu’un réglage global, afin qu’un appelant demande ce dont il a besoin et que le gestionnaire le satisfasse :
from dataclasses import dataclass
from typing import Optional
import itertools, time
@dataclass
class Lease:
country: str
session: Optional[str] = None # None means rotate per request
ttl: Optional[int] = None # only meaningful with a session
def username(self, customer="USERNAME"):
parts = [f"customer-{customer}", f"country-{self.country}"]
if self.session:
parts.append(f"sid-{self.session}")
if self.ttl:
parts.append(f"ttl-{self.ttl}")
return "-".join(parts)
def proxies(self, password="PASSWORD", host="p.shifter.io:443"):
url = f"http://{self.username()}:{password}@{host}"
return {"http": url, "https": url}
Le travail du gestionnaire consiste à produire un Lease, à le remettre à l’appelant, et à observer ce qui lui arrive.
Santé : suivre les sessions, pas les adresses
Voici où la plupart des gestionnaires maison se trompent. Sur une passerelle mutualisée, vous ne détenez pas une liste d’adresses IP à marquer comme bonnes ou mauvaises, car vous ne les avez jamais choisies et vous ne pouvez pas les conserver. Ce que vous pouvez suivre, c’est la santé d’une session, et la santé agrégée d’une route, c’est-à-dire une combinaison de pays et de cible.
Maintenez donc deux choses. Par session persistante active, un petit enregistrement des échecs consécutifs et des réponses de challenge, afin qu’une session manifestement devenue mauvaise puisse être retirée et remplacée par un identifiant frais. Par route, un taux de réussite glissant, qui vous indique si toute une combinaison pays-cible s’est dégradée plutôt qu’une seule session malchanceuse.
Un contrôle de santé dans ce contexte n’est pas un ping périodique. Faire un ping sur un exit vous dit qu’il peut atteindre un point de terminaison de test, ce qui n’est pas la question ; la question est de savoir s’il peut atteindre votre cible et obtenir du contenu réel. Utilisez donc un contrôle de santé passif : chaque requête réelle est le contrôle de santé, et son résultat validé met à jour l’enregistrement. Le sondage actif ne vaut le coup que pour un canari petit et peu coûteux contre chaque cible, ce qui est utile pour distinguer « cette cible est en panne pour tout le monde » de « nos routes vers elle se sont dégradées ».
Fondamentalement, jugez la santé sur des réponses validées plutôt que sur des codes de statut. Une page de challenge avec un 200 est un échec du point de vue de la santé, et un gestionnaire qui la compte comme un succès continuera de réutiliser une session au sujet de laquelle la cible a déjà tranché. C’est la même discipline de validation que dans détecter le contenu bloqué ou factice.
class RouteHealth:
def __init__(self, window=50):
self.window, self.results = window, []
def record(self, ok: bool):
self.results.append(ok)
if len(self.results) > self.window:
self.results.pop(0)
@property
def success_rate(self):
return sum(self.results) / len(self.results) if self.results else 1.0
@property
def degraded(self):
return len(self.results) >= 10 and self.success_rate < 0.7
Gestion des échecs : classer, puis agir
Le gestionnaire possède la décision de ce que signifie un échec, ce qui empêche cette logique d’être réinventée par job. Trois classes couvrent cela.
Les signaux de limitation signifient ralentir : un 429 ou un Retry-After. La bonne réponse est d’attendre, et spécifiquement de ne pas basculer vers un exit frais afin que le même rythme puisse continuer, car c’est ce qui transforme une route limitée en une route grillée.
Les signaux d’identité signifient que cet exit est terminé pour cette cible : une page de blocage, un 403 persistant, ou des challenges répétés sur une session. La bonne réponse est de retirer la session, d’en prendre une fraîche, et de continuer, ce qui correspond au schéma de failover.
Les erreurs terminales signifient s’arrêter : un 404, une URL malformée, ou un 407, qui est un problème d’authentification qui sera tout aussi erroné à chaque nouvelle tentative et devrait échouer bruyamment plutôt que de boucler, comme expliqué dans corriger les erreurs 407 et d’identifiants.
Tout le reste est transitoire et reçoit un backoff exponentiel avec jitter, borné par un plafond de tentatives, selon retry et backoff. Le point architectural important est que la nouvelle tentative réside dans le gestionnaire, de sorte que chaque appelant hérite du même comportement, et le gestionnaire peut faire respecter un budget de nouvelles tentatives entre les appelants plutôt que chaque job retentant indépendamment contre la même cible en difficulté.
Ajoutez un disjoncteur (circuit breaker) par route pendant que vous y êtes. Quand la santé d’une route tombe sous un seuil, arrêtez de lui envoyer du trafic pendant un temps de repos, puis autorisez un filet pour tester la reprise. Cela protège la cible contre votre accumulation et protège votre compte contre l’accumulation d’échecs face à un site qui ne répond à personne.
Le rythme (pacing) appartient ici aussi
Puisque le gestionnaire voit chaque requête sortante, c’est l’endroit naturel pour faire respecter des limites de débit par cible et des plafonds de concurrence, ce qui est le mécanisme décrit dans limitation de débit et régulation des requêtes. Le placer ici présente un avantage précis : les nouvelles tentatives respectent automatiquement le limiteur, car elles passent par le même chemin que n’importe quelle autre requête. Une nouvelle tentative qui contourne le rythme est ce qui transforme une cible en difficulté en une cible bloquée.
Assembler le tout
L’ensemble de la surface est réduit, ce qui est le but recherché :
class ProxyManager:
def __init__(self, limiter_factory, health_factory):
self.limiters = {} # host -> TargetLimiter
self.health = {} # (country, host) -> RouteHealth
self.limiter_factory, self.health_factory = limiter_factory, health_factory
def get(self, url, host, country, session=None, attempts=4):
route = self.health.setdefault((country, host), self.health_factory())
limiter = self.limiters.setdefault(host, self.limiter_factory(host))
sid = session
for attempt in range(attempts):
if route.degraded:
raise RouteUnavailable(country, host) # circuit open
limiter.acquire() # pacing, retries included
lease = Lease(country=country, session=sid, ttl=600 if sid else None)
outcome = self._send(url, lease) # returns (klass, response)
route.record(outcome.klass == "ok")
if outcome.klass == "ok":
return outcome.response
if outcome.klass == "terminal":
raise TerminalError(url, outcome.response)
if outcome.klass == "identity" and sid:
sid = new_session_id() # retire, do not reuse
if outcome.klass == "rate":
time.sleep(outcome.retry_after or backoff(attempt))
else:
time.sleep(backoff(attempt)) # transient
raise Exhausted(url)
Deux remarques de conception. Le gestionnaire renvoie des réponses et lève des erreurs typées plutôt que de renvoyer None, afin que les appelants ne puissent pas traiter silencieusement un échec comme une donnée vide. Et il n’avale jamais une erreur terminale, car un problème d’authentification ou de configuration devrait arrêter un job plutôt que d’être retenté jusqu’à disparaître dans l’invisible.
Considérations opérationnelles
Rendez-le observable. Le gestionnaire voit chaque requête, c’est donc là que le taux de réussite par route, le ratio de nouvelles tentatives, et les octets par requête devraient être émis, ce qui est exactement la donnée à partir de laquelle sont construites les métriques dans les KPI de proxy et ce que consomme la surveillance de pipeline.
Partagez l’état entre les processus. Un gestionnaire par processus multiplie votre débit effectif par le nombre de workers et fragmente le suivi de santé. Dans un déploiement distribué, les limiteurs, les disjoncteurs et la santé des routes doivent résider dans un stockage partagé, ce qui est la même considération que pour la mise à l’échelle avec Kubernetes.
Gardez les identifiants en dehors de celui-ci. Le gestionnaire compose les noms d’utilisateur ; il devrait lire le mot de passe depuis un coffre-fort de secrets plutôt que de le conserver dans la configuration, et la rotation devrait être une étape de déploiement plutôt qu’un changement de code.
Ne suradaptez pas. Résistez à une architecture de plugins pour des fournisseurs hypothétiques. Une seule passerelle, exprimée proprement, est plus facile à raisonner qu’une couche d’abstraction au-dessus de fournisseurs que vous n’utilisez pas.
L’essentiel
Un gestionnaire de proxy est le composant qui décide comment une requête sort de votre système et ce qui se passe en cas d’échec, et centraliser ces décisions est ce qui empêche la même logique d’être réimplémentée, de façon incohérente, dans chaque job. Donnez aux appelants un bail à portée de requête afin que la rotation, la persistance et la géographie soient par job plutôt que globales. Suivez la santé au niveau de la session et de la route plutôt que de prétendre gérer des adresses individuelles, et jugez-la sur des réponses validées afin qu’une page de challenge compte comme l’échec qu’elle est. Possédez la classification des échecs en un seul endroit : attendez sur les signaux de limitation, retirez la session sur les signaux d’identité, échouez bruyamment sur les erreurs terminales, et reculez avec du jitter sur tout le reste, avec un disjoncteur par route. Placez le rythme dans le même chemin pour que les nouvelles tentatives ne puissent pas le contourner. Puis émettez des métriques à partir de lui, car c’est le seul composant qui voit tout.
Dessous se trouve le réseau de proxy résidentiel : une seule passerelle où le pays, la ville, la session et le TTL sont tous exprimés dans le nom d’utilisateur, ce qui fait qu’un gestionnaire comme celui-ci représente une petite quantité de code plutôt qu’un projet d’intégration, avec une tarification par GB qui fait que l’efficacité que cela vous procure apparaît directement sur la facture.