Lógica de Reintentos para Scrapers: 6 Errores que Convierten Fallos en Bloqueos
Tras unos cientos de páginas, tu scraper empieza a recibir 429 Too Many Requests. El wrapper de reintentos vuelve a enviar esas peticiones. Poco después, la mayoría de las respuestas son 403 o páginas de desafío.
Tu parser no ha cambiado. Tu tráfico sí: los reintentos añadieron peticiones justo cuando el sitio empezó a rechazarlas. Una política de reintentos que trata cada fallo como un problema temporal del servidor puede empeorar un límite de tasa o un bloqueo anti-bot.
La solución empieza por clasificar la respuesta. Después decides si esperar, cambiar la identidad del cliente o parar. Estos seis errores sabotean esa decisión.
1. Reintentar de Inmediato
Este es el bucle de reintentos con el que empieza casi todo scraper:
for attempt in range(3):
response = session.get(url)
if response.status_code == 200:
break
Una petición rechazada se convierte en tres peticiones seguidas. Si la respuesta era un límite de tasa, el bucle aumenta el tráfico justo en el peor momento.
Los valores por defecto de las librerías no siempre añaden una espera:
urllib3.util.Retry, que gestiona los reintentos de los adaptadores derequests, usa por defectobackoff_factor=0. No añade backoff a menos que lo configures.- El
RetryMiddlewarede Scrapy reintenta408,429,500,502,503,504,522y524hastaRETRY_TIMES = 2veces adicionales. No tiene ningún ajuste de espera entre reintentos. Con el valor por defectoRETRY_PRIORITY_ADJUST = -1, el reintento vuelve al scheduler con menor prioridad y puede salir en cuanto haya un slot de descarga libre.
Usa backoff exponencial con jitter. El backoff alarga la espera tras cada fallo. El jitter la vuelve aleatoria, para que los workers que fallan a la vez no reintenten todos a la vez.
El enfoque “full jitter” que describe el artículo Exponential Backoff And Jitter del AWS Architecture Blog elige una espera aleatoria entre cero y min(cap, base * 2 ** attempt).
Esta configuración usa requests con urllib3 2.x para añadir backoff y jitter en la capa del adaptador HTTP. Úsala solo si esa capa es la dueña de tus reintentos.
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))
El backoff_jitter de urllib3 suma una cantidad aleatoria al backoff calculado. Es jitter aditivo, no full jitter: los workers siguen compartiendo la misma curva de backoff. Con tenacity, wait_random_exponential(multiplier=1, max=60) te da full jitter directamente.
Cuando se agotan los reintentos con un código de status_forcelist, requests lanza requests.exceptions.RetryError en lugar de devolver la última respuesta. Las capas superiores deben registrar ese fallo, no arrancar otro bucle de reintentos.
2. Ignorar Retry-After
Antes de calcular tu propia espera, mira Retry-After. Un 429 o un 503 pueden usarlo para decirte cuándo volver a intentarlo.
Según MDN, la cabecera acepta un número de segundos, como Retry-After: 120, o una fecha HTTP. Tu código tiene que manejar ambas formas.
Si el servidor pide 120 segundos y tu backoff elige dos, sigues dentro de la ventana. Algunos limitadores de tasa también cuentan las peticiones rechazadas, así que los intentos prematuros pueden mantenerte dentro.
Cada librería se comporta de forma distinta:
- urllib3:
respect_retry_after_headerestá activado por defecto. Su tratamiento especial cubre413,429y503; estas respuestas pueden reintentarse conRetry-Afteraunque no estén enstatus_forcelist, siempre que lo permitan las demás condiciones de reintento. Las versiones que exponenretry_after_maxlimitan esta espera a seis horas por defecto. - tenacity: no interpreta respuestas HTTP. Tu función de espera tiene que leer y parsear la cabecera.
- httpx:
HTTPTransport(retries=3)reintentaConnectErroryConnectTimeout, no códigos de estado HTTP. Un429vuelve directamente a tu código.
Esta función de espera para tenacity usa el tiempo que indica el servidor cuando lo hay y, si no, recurre a 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
No extiendas esta política basada en códigos de estado a todos los 403. Una respuesta de desafío necesita clasificarse antes de otro intento, como verás en el error 5.
3. Reintentar Respuestas que No Son Fallos
Una respuesta corta no es necesariamente una respuesta fallida. Los endpoints JSON o AJAX paginados pueden devolver una lista vacía, un cuerpo vacío o un pequeño fragmento HTML después de la última página. Ese fragmento puede contener un doctype y unos pocos elementos <li> sin un <html> o <head> completo.
Si tu validador trata todas esas formas como rotas, los reintentos seguirán descargando la misma respuesta válida.
Valida contra lo que el endpoint debería devolver, no contra una longitud mínima del cuerpo. Si dos identidades de cliente distintas devuelven la misma respuesta corta, probablemente es la respuesta real. Revisa el estado de la paginación antes de reintentar.
El error inverso es aceptar una página de desafío porque su código es 200. Validar solo el código de estado deja que ese HTML entre en tu conjunto de datos en lugar de activar el manejo de bloqueos.
Comprueba tanto el código como el contenido que necesitas extraer. Consulta HTTP 200 But Blocked: Detecting Soft Blocks para las señales de desafío y Truncated HTML Responses para los cuerpos incompletos.
4. Fijar el País del Proxy en Cada Intento
La segmentación por país limita las salidas de proxy que tu scraper puede usar. Un proveedor puede tener un gran pool residencial en total, pero relativamente pocas salidas en el país que eliges.
Si cada petición y cada reintento deben usar ese subconjunto, las peticiones se concentran en menos direcciones IP. El tráfico por IP sube y el sitio puede responder con bloqueos o CAPTCHAs de deslizador. Rotar el proxy sin quitar la restricción de país no te da necesariamente un pool muy diferente.
Primero, comprueba si la restricción de país es necesaria:
- Descarga la misma página a través de dos países.
- Compara los campos que tu scraper usa de verdad, como precio, moneda, stock e idioma.
- Si los campos relevantes coinciden, quita la restricción de país.
- Si difieren, mantén el país necesario. Al reintentar, espera y baja la concurrencia en lugar de sacar otra salida del mismo pool pequeño.
Un mercado cercano solo es una alternativa después de verificar que sirve la misma versión relevante de la página. No cambies datos correctos por una petición más fácil.
5. Reintentar un Bloqueo con la Misma Identidad
Un 403 por sí solo no te dice por qué falló el acceso: puede ser un requisito de login, una regla del sitio o una decisión anti-bot. Cuando Cloudflare, DataDome o Akamai devuelven un desafío, la decisión tiene en cuenta tu dirección IP, tu huella TLS, tus cabeceras y tus cookies de sesión. Repetir el mismo estado de cliente envía otra vez las mismas señales.
Cloudflare marca explícitamente sus respuestas de desafío: cf-mitigated: challenge. Revisa esa cabecera además del cuerpo. Otras respuestas de desafío necesitan sus propias comprobaciones de contenido.
Usa la clasificación de la respuesta para elegir el siguiente paso:
| Respuesta | Interpretación Probable | Antes de Reintentar |
|---|---|---|
429 con Retry-After | Límite de tasa | Espera el tiempo indicado y reduce la concurrencia para ese host |
502, 503, 504, 522 o 524 sin marcas de desafío | Fallo del origen o del edge | Aplica backoff; mantener la misma identidad puede ser adecuado |
403 con cf-mitigated: challenge o HTML de desafío reconocible | Desafío anti-bot | Usa una IP nueva y cookies de sesión nuevas, o un cliente distinto |
200 con un CAPTCHA o HTML de “verifica que eres humano” | Bloqueo suave | Trátalo como un desafío, no como una página correcta |
| Conexión reiniciada durante el handshake TLS | Fallo de red o posible bloqueo en el edge | Permite un reintento con una conexión y un proxy nuevos |
404 o 410 | Página inexistente o eliminada | No la reintentes como un fallo transitorio |
Ante un bloqueo confirmado, escala de los cambios más baratos a los más caros: una sesión de proxy nueva, luego otra huella de cliente y luego un navegador con Playwright. Por ejemplo, curl_cffi ofrece un objetivo impersonate para elegir la huella del cliente. Cámbialo a propósito en lugar de repetir la misma configuración bloqueada.
Cada escalón añade tiempo y coste. Pasa al siguiente solo cuando el anterior falle, y mantén toda la escalera dentro de un único presupuesto de intentos.
Aísla también las URLs que fallan una y otra vez. Si una URL se bloquea con dos o tres identidades mientras otras páginas del mismo host cargan bien, deja de gastar identidades que funcionan en ella. Puede haber una regla más estricta en esa ruta o una trampa. Apártala para revisarla.
6. Dejar que Cada Capa Reintente
Las capas de reintento independientes multiplican los intentos. No solo los suman.
Supón que tu cliente HTTP reintenta tres veces, lo que da cuatro intentos. Un middleware de Scrapy o tu propio wrapper añade dos reintentos, lo que da tres ejecuciones de esa operación del cliente. Después la cola de tareas vuelve a ejecutar la tarea fallida; autoretry_for de Celery usa por defecto max_retries igual a tres, lo que da cuatro ejecuciones de la tarea.
El total es 4 × 3 × 4 = 48 peticiones para una sola URL que sigue fallando. Cuando muchas URLs fallan a la vez, todas esas capas amplifican el tráfico mientras el sitio ya está rechazando peticiones.
El capítulo sobre gestión de sobrecarga del libro SRE de Google describe esta misma amplificación de reintentos. Sus controles se aplican directamente al diseño de un scraper:
- Elige un único dueño de los reintentos. Las demás capas le devuelven los fallos en lugar de lanzar sus propios bucles.
- Limita los intentos por petición. El libro permite tres fallos antes de devolver el error a quien hizo la llamada.
- Aplica un presupuesto de reintentos. Su ejemplo solo permite reintentar mientras los reintentos sigan por debajo del 10 % de todas las peticiones.
En un scraper, lleva ese presupuesto por host. Añade un circuit breaker que detenga temporalmente las peticiones a un host cuando la tasa de bloqueos supere tu umbral. Sin él, un bucle de reintentos acotado todavía puede saturar un host cuando suficientes workers lo ejecutan a la vez. El presupuesto limita el tráfico extra; el breaker pone en pausa un host que rechaza demasiadas peticiones:
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 tiene que hacer la clasificación de respuestas de los errores 3 y 5. Si se permite otro intento tras un bloqueo, debe preparar el cambio de identidad antes de que ese intento se ejecute.
El bucle de ejemplo es secuencial. Con asyncio o un pool de hilos, comparte un único HostGuard entre los workers que atacan el mismo host. Con guards separados, cada worker tendría su propio presupuesto y quedaría oculta la tasa de fallos total.
El coste: estos controles dejan algunas URLs sin terminar a propósito. Renuncias a completar una ejecución para no perder el host durante las diez siguientes. Aparta el resto para una ejecución posterior a menor ritmo.
Además, un breaker no distingue una caída del sitio de un bloqueo sobre tu identidad; ambos lo disparan. Revisa respuestas guardadas antes de decidir si cambias de cliente o simplemente esperas.
Preguntas Frecuentes
¿Cuántas veces debería reintentar un scraper una petición fallida?
Dos o tres intentos en total por URL, gestionados por una sola capa, bastan para fallos transitorios como timeouts y respuestas 503. Si sigue fallando, para el bucle actual, aparta la URL y pruébala en una ejecución posterior.
¿Debo reintentar una respuesta 403 Forbidden?
No a ciegas con la misma petición. Primero distingue una respuesta anti-bot de una restricción de acceso. Ante un bloqueo anti-bot, cambia la IP, la huella o el estado de sesión antes de otro intento. Cambiar de identidad no te va a dar un login o un permiso que falta.
¿Cuál es la diferencia entre backoff exponencial y jitter?
El backoff exponencial alarga la espera entre intentos, por ejemplo 1, 2, 4 y 8 segundos. El jitter vuelve aleatoria la espera para que los workers que fallan juntos no reintenten juntos. Usa ambos y respeta Retry-After cuando el servidor lo envíe.
¿Scrapy espera entre reintentos?
No. RetryMiddleware vuelve a programar la petición con menor prioridad y sin espera. Activa AutoThrottle (AUTOTHROTTLE_ENABLED = True), que nunca deja que una respuesta distinta de 200 reduzca la espera, mantén RETRY_TIMES bajo y escribe tu propio middleware si necesitas un backoff real por petición.
Cuenta Primero Tus Capas de Reintento
Abre tu scraper y anota cada sitio donde se reintenta: el adaptador HTTP, los ajustes de Scrapy, tu wrapper, la cola de tareas y cualquier reejecución programada de lotes fallidos. Multiplica los intentos. Si el resultado pasa de tres, elige un único dueño y desactiva los demás bucles de reintento antes de ajustar las esperas.
Si prefieres no mantener los cambios de proxy, huella y navegador entre intentos, ScrapeUnblocker se encarga de los proxies, el renderizado y los bloqueos por su lado. Su documentación de errores indica que un bloqueo 403, un timeout o un 429 no se cobran. Así puedes mantener tu política de reintentos en un solo sitio, alrededor de esa llamada a la API.
Prueba ScrapeUnblocker gratis
Tasa de éxito del 95%+ · desde 0,55 € por cada 1000 llamadas (puede aplicarse IVA) · 500 solicitudes gratis al registrarte.