Existe una lógica tentadora que atrapa a muchos equipos: los límites de velocidad se aplican por IP, un pool residencial te da muchas IPs, por lo tanto los límites de velocidad ya no se aplican. Las dos primeras premisas son ciertas y la conclusión es errónea, y en la brecha entre ambas es donde vive un número sorprendente de scrapers bloqueados.
Un pool cambia dónde se sitúa el límite, no si este existe. Sigues teniendo que regular el ritmo, solo que a lo largo de ejes diferentes a los de una configuración de una sola IP, y el eje que la gente olvida es el que hace que los detecten. Así es como funciona realmente la limitación cuando tus solicitudes se reparten entre un pool rotativo.
Tres límites, no uno
Cuando haces scraping a través de un pool, al menos tres techos distintos se aplican a la vez, y tu rendimiento lo determina el que alcances primero.
Por IP, por objetivo. Este es el clásico. Cualquier dirección individual solo puede enviar un número determinado de solicitudes a un sitio dado antes de que ese sitio la limite o la bloquee. La rotación es lo que te mantiene por debajo de este límite, que es precisamente el objetivo de distribuir la carga a través de un pool, como se describe en balanceo de carga.
Por objetivo, en conjunto. Este es el que sorprende a la gente. Un sitio no solo cuenta por dirección; también observa su propia carga total entrante y puede reconocer un patrón coordinado a través de muchas direcciones, sobre todo si las solicitudes comparten temporización, rutas o comportamiento. Tus cincuenta IPs enviando dos solicitudes cada una por segundo son cien solicitudes por segundo llegando a un mismo origen, y ninguna cantidad de rotación de direcciones hace que eso parezca tráfico orgánico normal. Los sistemas anti-bot razonan cada vez más a este nivel.
Tu propia capacidad. La concurrencia que realmente puedes sostener: conexiones abiertas, hilos de trabajo, memoria, y el hecho de que la latencia residencial es más alta que una conexión directa, por lo que un número fijo de hilos produce menos solicitudes por segundo de lo que podrías esperar.
La consecuencia práctica es que tu limitador debe expresarse por objetivo en lugar de globalmente. Un único límite de velocidad global o bien te asfixia en los cincuenta sitios que podrían soportar más, o bien golpea con fuerza al que no puede.
Empieza por el objetivo, no por tus ambiciones
Antes de escribir código de limitación, averigua qué tolera realmente el objetivo, porque adivinar produce o bien un trabajo innecesariamente lento o uno bloqueado.
Lee las señales que da el sitio. Un 429 es una afirmación explícita de que vas demasiado rápido. Una cabecera Retry-After es el sitio diciéndote exactamente cuánto tiempo esperar, y respetarla es tanto correcto como mucho más económico que averiguar la respuesta por ensayo. Algunas APIs publican límites en su documentación o devuelven cabeceras con la cuota restante. Y si el sitio tiene un robots.txt con una directiva crawl-delay, esa es una preferencia declarada que merece la pena respetar.
Cuando no hay nada publicado, calibra de forma empírica: empieza de forma conservadora, aumenta gradualmente, y observa la tasa de éxito validada en lugar de los códigos de estado, ya que un sitio bajo presión a menudo se degrada antes de negarse, y una página de desafío con un estado 200 parece un éxito para un contador ingenuo, como se trata en detectar contenido bloqueado o falso. El punto en el que la tasa de éxito empieza a caer es tu techo real, y quieres operar por debajo de él, no en él.
Implementar el limitador
Un token bucket por objetivo es la herramienta estándar y es lo bastante sencilla como para escribirla desde cero. Los tokens se recargan a la tasa elegida, cada solicitud consume uno, y una solicitud espera cuando el bucket está vacío. Esto proporciona una media estable con una tolerancia de ráfaga controlada, que se ajusta mejor al comportamiento del tráfico real que un retraso rígido de una solicitud por cada N segundos.
import time, threading
class TargetLimiter:
"""Token bucket, one instance per target host."""
def __init__(self, rate_per_sec, burst=5):
self.rate, self.capacity = rate_per_sec, burst
self.tokens, self.updated = burst, time.monotonic()
self.lock = threading.Lock()
def acquire(self):
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return
wait = (1 - self.tokens) / self.rate
time.sleep(wait) # sleep outside the lock
LIMITS = { # per target, tuned per target
"shop.example.com": TargetLimiter(2.0),
"search.example.com": TargetLimiter(0.5),
}
def fetch(url, host, proxies):
LIMITS[host].acquire()
return requests.get(url, proxies=proxies, timeout=20)
Dos mejoras importan en producción. Añade jitter para que las solicitudes no caigan en intervalos exactos, ya que un espaciado perfectamente regular es en sí mismo una firma de máquina, y además sincroniza tus workers en oleadas. Y haz que el limitador se comparta entre workers en lugar de por proceso, porque de lo contrario diez procesos haciendo educadamente dos solicitudes por segundo cada uno están haciendo colectivamente veinte. En una configuración distribuida esto significa un contador compartido en Redis o similar, que es el mismo problema que tiene que resolver escalar el scraping en Kubernetes.
La concurrencia es la otra mitad
La tasa y la concurrencia son diales distintos y ambos necesitan límites. La tasa controla con qué frecuencia inicias solicitudes; la concurrencia controla cuántas están en curso simultáneamente. Un trabajo con un límite de tasa modesto pero concurrencia sin límite igualmente abrirá cientos de conexiones simultáneas a un mismo origen en el momento en que ese origen se ralentice, porque las respuestas lentas hacen que las solicitudes se acumulen.
Limita la concurrencia por objetivo con un semáforo, y ajusta su tamaño según lo que tolere el objetivo en lugar de lo que tu máquina pueda abrir. En el lado del proxy, las conexiones concurrentes generalmente no son tu limitación, lo que hace fácil olvidar que la tolerancia del objetivo sí lo es. La cuestión de distribución de cuántas direcciones implica esto se trata en cuántas IPs de proxy necesitas realmente.
La limitación adaptativa supera a un número fijo
Una tasa estática es una suposición que se vuelve errónea. Los sitios cambian su tolerancia, se ralentizan bajo carga, o endurecen los límites en horas punta. El patrón más robusto es ajustar según lo que se observa, al estilo del incremento aditivo y la disminución multiplicativa: sube la tasa lentamente mientras las cosas van bien, y recórtala con fuerza al primer signo de tensión.
Activa la disminución con una señal compuesta en lugar de un único código de estado: un 429, una proporción creciente de páginas de desafío, una latencia en aumento, o una caída en la tasa de éxito validada. Luego recupera gradualmente en lugar de saltar directamente de vuelta a la tasa anterior, ya que un retorno inmediato a la velocidad completa tras un bloqueo es en sí mismo un patrón reconocible.
class AdaptiveRate:
def __init__(self, start=2.0, floor=0.2, ceiling=10.0):
self.rate, self.floor, self.ceiling = start, floor, ceiling
def ok(self): # healthy response
self.rate = min(self.ceiling, self.rate * 1.02) # creep up
def strained(self): # 429, challenge, timeout, latency spike
self.rate = max(self.floor, self.rate * 0.5) # back off hard
Dónde la limitación se cruza con la rotación y los reintentos
Vale la pena ser explícito sobre tres interacciones, porque hacerlas mal deshace la limitación.
No respondas a una señal de tasa rotando. Un 429 significa que reduzcas la velocidad. Cambiar a una dirección nueva para poder mantener el mismo ritmo es el comportamiento que convierte una dirección limitada en un pool quemado, y es precisamente lo que hace que el tráfico parezca evasivo en lugar de simplemente entusiasta. Espera cuando la señal es sobre la tasa; rota cuando la señal es sobre la dirección.
Los reintentos deben respetar el limitador. Un reintento es otra solicitud y debe adquirir un token como cualquier otra, o tu ruta de error eludirá silenciosamente el ritmo que construiste. Las tormentas de reintentos son la forma más común en que un trabajo limitado se convierte en uno bloqueado, tema que trata reintentos y backoff.
Las sesiones persistentes concentran la carga. Mantener una dirección para un flujo de varios pasos significa que esa dirección soporta toda la secuencia, por lo que el ritmo por IP importa más dentro de una sesión persistente que en un trabajo rotativo donde la carga se distribuye de forma natural.
La cortesía es interés propio
Es fácil leer todo esto como una carga de cumplimiento normativo, pero los incentivos apuntan en la misma dirección. Regular el ritmo preserva la reputación de las direcciones de las que dependes, mantiene tu tasa de éxito alta para que no estés pagando ancho de banda por páginas de desafío, y evita el ciclo de escalada en el que una recolección agresiva provoca defensas más estrictas que hacen el objetivo más difícil para todos, tú mismo incluido el próximo trimestre. Respetar los límites declarados de un sitio, sus directivas robots y sus términos es tanto lo correcto como lo más económico.
La conclusión
Un pool no elimina los límites de velocidad, los desplaza. Limita por objetivo en lugar de globalmente, porque un único número es incorrecto para cada sitio que tocas. Averigua la tolerancia real de cada objetivo a partir de sus propias señales, respeta Retry-After y 429 como instrucciones en lugar de obstáculos, y calibra según la tasa de éxito validada en lugar de los códigos de estado. Implementa con un token bucket compartido más jitter, limita la concurrencia por separado de la tasa, y haz que el límite sea adaptativo para que se degrade rápido y se recupere despacio. Luego mantén claras las interacciones: nunca respondas a una señal de ritmo rotando direcciones, dirige siempre los reintentos a través del limitador, y regula con más fuerza dentro de las sesiones persistentes. Bien hecho, la limitación es lo que permite que un pool funcione a su capacidad real en lugar de agotarla.
El propio pool es lo que te da margen para trabajar: los proxies residenciales reparten un trabajo correctamente regulado entre muchas direcciones reales de tipo doméstico con segmentación por país y ciudad, y el precio por GB significa que un trabajo que regula bien el ritmo y solo obtiene lo que necesita cuesta menos que uno que golpea con fuerza y reintenta.