Scraping

Construyendo un gestor de proxies residenciales: rotación, comprobaciones de estado y reintentos

La lógica que decide qué salida usar, cuándo retirarla y cómo recuperarse debe estar en un solo componente. A continuación se explica cómo diseñarlo y qué estado necesita.

Chris Collins

Chris Collins

26 de agosto de 2026 · 10 min de lectura

La mayoría de las bases de código de scraping acaban con un gestor de proxies, lo diseñe alguien o no. Empieza como un ayudante que construye una URL de proxy, luego alguien añade un reintento, después un caso especial para un objetivo que necesita sesiones persistentes, luego un contador para dejar de machacar una dirección que sigue fallando. Dieciocho meses después esa lógica está repartida entre cuatro módulos y nadie sabe qué ocurre cuando una petición falla dos veces en la misma sesión.

Merece la pena construirlo de forma deliberada, porque las decisiones implicadas son realmente compartidas: qué salida usa esta petición, si esa salida está sana, qué ocurre cuando falla y cómo se regula el ritmo. Aquí tienes un diseño para ese componente y el estado que necesita mantener.

De qué es responsable el gestor

Traza el límite con precisión. Un gestor de proxies decide cómo sale una petición de tu infraestructura y qué hacer cuando eso falla. No analiza páginas, no sabe qué aspecto tiene un listado de productos y no decide qué URLs visitar. Mantenerlo ajeno a tu lógica de negocio es lo que permite que todos los trabajos lo compartan.

Eso deja cuatro responsabilidades: seleccionar una salida para cada petición según una política, hacer seguimiento de la salud de lo que reparte, aplicar la gestión de fallos cuando algo va mal y hacer cumplir el ritmo para que los llamadores no puedan saturar individualmente un objetivo. Todo lo demás pertenece a otro lugar.

Selección: la política es por trabajo, no global

Lo primero que necesita el gestor es una noción del tipo de salida que quiere un llamador, en lugar de un único modo global. En la práctica hay tres.

Rotativa, donde cada petición obtiene una salida nueva. Es el modo por defecto para la recolección masiva y se expresa omitiendo un identificador de sesión, que es como está pensado usarse el modelo de rotación.

Persistente (sticky), donde un llamador mantiene una salida para una secuencia, y el gestor debe devolver el mismo identificador de sesión durante la vida de esa secuencia, y luego liberarlo. Las contrapartidas están en sticky frente a rotativo.

Anclada a geografía (geo-pinned), donde la salida debe estar en un país o ciudad concreto, lo cual para trabajo multimercado es ortogonal a los dos anteriores: un trabajo puede ser rotativo-en-Alemania o persistente-en-Chicago.

Modela eso como un arrendamiento (lease) de ámbito de petición en lugar de un ajuste global, de modo que un llamador pida lo que necesita y el gestor lo satisfaga:

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}

El trabajo del gestor es producir un Lease, entregárselo al llamador y observar qué le ocurre.

Salud: rastrea sesiones, no direcciones

Aquí es donde la mayoría de los gestores caseros se equivocan. En una pasarela agrupada no mantienes una lista de direcciones IP para marcar como buenas o malas, porque nunca las elegiste y no tienes garantía de conservarlas. Lo que sí puedes rastrear es la salud de una sesión, y la salud agregada de una ruta, es decir, una combinación de país y objetivo.

Así que mantén dos cosas. Por sesión persistente activa, un pequeño registro de fallos consecutivos y respuestas de desafío, de modo que una sesión que claramente se ha estropeado pueda retirarse y sustituirse por un identificador nuevo. Por ruta, una tasa de éxito móvil, que te indica si toda una combinación país-objetivo se ha degradado en lugar de una sesión con mala suerte.

Una comprobación de salud en este contexto no es un ping periódico. Hacer ping a una salida te dice que puede alcanzar un endpoint de prueba, lo cual no es la pregunta; la pregunta es si puede alcanzar tu objetivo y obtener contenido real. Así que usa comprobación de salud pasiva: cada petición real es la comprobación de salud, y su resultado validado actualiza el registro. El sondeo activo merece la pena solo para un canario pequeño y barato contra cada objetivo, útil para distinguir “este objetivo está caído para todo el mundo” de “nuestras rutas hacia él se han degradado”.

Fundamentalmente, juzga la salud según respuestas validadas y no según códigos de estado. Una página de desafío con un 200 es un fallo a efectos de salud, y un gestor que la cuente como éxito seguirá reutilizando una sesión sobre la que el objetivo ya se ha pronunciado. Es la misma disciplina de validación que en detectar contenido bloqueado o falso.

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

Gestión de fallos: clasifica y luego actúa

El gestor posee la decisión de qué significa un fallo, y eso es lo que evita que esa lógica se reinvente en cada trabajo. Tres clases lo cubren.

Señales de límite de ritmo significan reducir la velocidad: un 429 o un Retry-After. La respuesta correcta es esperar, y específicamente no cambiar a una salida nueva para poder mantener el mismo ritmo, ya que eso es lo que convierte una ruta limitada en una ruta quemada.

Señales de identidad significan que esta salida está acabada para este objetivo: una página de bloqueo, un 403 persistente o desafíos repetidos en una sesión. La respuesta correcta es retirar la sesión, tomar una nueva y continuar, que es el patrón de failover.

Errores terminales significan detenerse: un 404, una URL malformada o un 407, que es un problema de autenticación que será igualmente erróneo en cada reintento y debería fallar de forma sonora en lugar de repetirse en bucle, como se trata en corregir errores 407 y de credenciales.

Todo lo demás es transitorio y recibe backoff exponencial con jitter, acotado por un límite de intentos, según retry y backoff. El punto arquitectónico importante es que el reintento vive en el gestor, de modo que todos los llamadores heredan el mismo comportamiento, y el gestor puede hacer cumplir un presupuesto de reintentos entre llamadores en lugar de que cada trabajo reintente de forma independiente contra el mismo objetivo con problemas.

Añade también un disyuntor (circuit breaker) por ruta ya puestos. Cuando la salud de una ruta cae por debajo de un umbral, deja de enviarle tráfico durante un periodo de enfriamiento, y luego permite un goteo para probar la recuperación. Eso protege al objetivo de tu avalancha y protege tu cuenta de acumular fallos contra un sitio que no responde a nadie.

El ritmo también pertenece aquí

Dado que el gestor ve todas las peticiones salientes, es el lugar natural para hacer cumplir límites de ritmo por objetivo y topes de concurrencia, que es el mecanismo descrito en limitación de ritmo y throttling de peticiones. Ponerlo aquí tiene un beneficio concreto: los reintentos respetan automáticamente el limitador, porque pasan por el mismo camino que cualquier otra petición. Un reintento que se salta el ritmo es cómo un objetivo con problemas se convierte en uno bloqueado.

Juntándolo todo

Toda la superficie es pequeña, que es precisamente la idea:

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)

Dos notas de diseño. El gestor devuelve respuestas y lanza errores tipados en lugar de devolver None, de modo que los llamadores no puedan tratar en silencio un fallo como datos vacíos. Y nunca engulle un error terminal, porque un problema de autenticación o configuración debería detener un trabajo en lugar de reintentarse hasta hacerse invisible.

Consideraciones operativas

Hazlo observable. El gestor ve todas las peticiones, así que es el lugar donde deberían emitirse la tasa de éxito por ruta, el ratio de reintentos y los bytes por petición, que son exactamente los datos con los que se construyen las métricas en KPIs de proxies y lo que consume la monitorización de pipelines.

Comparte el estado entre procesos. Un gestor por proceso multiplica tu ritmo efectivo por el número de workers y fragmenta el seguimiento de salud. En un despliegue distribuido, los limitadores, disyuntores y la salud de las rutas pertenecen a un almacenamiento compartido, la misma consideración que en escalar con Kubernetes.

Mantén las credenciales fuera de él. El gestor compone nombres de usuario; debería leer la contraseña de un almacén de secretos en lugar de mantenerla en la configuración, y la rotación debería ser un paso de despliegue en lugar de un cambio de código.

No sobre-abstraigas. Resiste la tentación de una arquitectura de plugins para proveedores hipotéticos. Una sola pasarela, expresada con claridad, es más fácil de razonar que una capa de abstracción sobre proveedores que no usas.

La conclusión

Un gestor de proxies es el componente que decide cómo sale una petición de tu sistema y qué ocurre cuando falla, y centralizar esas decisiones es lo que evita que la misma lógica se reimplemente, de forma inconsistente, en cada trabajo. Dale a los llamadores un arrendamiento de ámbito de petición para que la rotación, la persistencia y la geografía sean por trabajo en lugar de globales. Rastrea la salud a nivel de sesión y de ruta en lugar de fingir que gestionas direcciones individuales, y júzgala según respuestas validadas para que una página de desafío cuente como el fallo que es. Posee la clasificación de fallos en un solo lugar: espera ante señales de límite de ritmo, retira la sesión ante señales de identidad, falla de forma sonora ante errores terminales, y aplica backoff con jitter en todo lo demás, con un disyuntor por ruta. Coloca el ritmo en el mismo camino para que los reintentos no puedan saltárselo. Después emite métricas desde él, porque es el único componente que lo ve todo.

Debajo se sitúa la red de proxies residenciales: una única pasarela donde el país, la ciudad, la sesión y el TTL se expresan todos en el nombre de usuario, que es lo que hace que un gestor como este sea una pequeña cantidad de código en lugar de un proyecto de integración, con precios por GB para que la eficiencia que compra se refleje directamente en la factura.

¿Listo para empezar?

Prueba los proxies residenciales de Shifter, más de 205M IPs, más de 195 países, desde 0,75 $/GB.

Comenzar