Todo scraper necesita lógica de reintentos, y la mayoría la desarrolla de forma accidental: un bloque try aquí, un time.sleep allá, un contador de intentos añadido la semana en que algo se rompió. El resultado funciona hasta el día en que un objetivo tiene una mala hora, momento en el que la ruta de reintento causa más daño del que causó el fallo original. El argumento de por qué ocurre esto está en retry and backoff; esto es la implementación.
La idea organizadora es que un reintento es una decisión tomada a partir de un fallo clasificado, no un bucle envuelto alrededor de una solicitud. Si se acierta con la clasificación, el resto se deduce.
Clasificar primero
Todo fallo cae en una de cuatro clases, y cada una tiene exactamente una respuesta correcta.
| Clase | Ejemplos | Respuesta |
|---|---|---|
| Transitorio | timeout, connection reset, 502, 503, 504 | reintentar con backoff |
| Rate | 429, Retry-After presente | esperar según lo indicado, misma ruta |
| Identidad | página de desafío, 403 persistente, página de bloqueo | nueva sesión, luego reintentar |
| Terminal | 404, 400, 401, 407, fallo de parseo | no reintentar, mostrarlo |
Dos de estas son las que la gente suele equivocar. Las señales de Rate son instrucciones sobre el ritmo, así que la respuesta es esperar en lugar de cambiar de dirección, porque rotar para sostener el mismo ritmo es exactamente el comportamiento que escala un throttle hasta convertirlo en un bloqueo. Los fallos Terminal nunca deben reintentarse: un 407 significa credenciales o un indicador de usuario mal formado y será igual de erróneo en el quinto intento, y un fallo de parseo es un bug en tu código que un reintento reproduce fielmente para siempre.
El quinto caso es el que nunca aparece en absoluto en un código de estado: una respuesta que parece correcta y no lo es. Una página de desafío, un conjunto de resultados vacío o un listado truncado servido con un 200 pertenece a la clase identidad, pero solo si te das cuenta, lo que significa validar el cuerpo antes de decidir nada. Esa comprobación es la base de todo el sistema, según detecting blocked or fake content.
Clasificación en código
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 es específico de cada objetivo y suele ser una lista corta de marcadores: una referencia a un script de captcha, un título de interstitial conocido, un cuerpo mucho más corto que una página real. Mantenlo en un solo lugar por objetivo para que tanto la ruta de reintento como tu monitorización usen la misma definición.
Backoff con jitter, y respetar Retry-After
Para la clase transitoria, el retraso crece exponencialmente y debe ser aleatorizado. Los retrasos fijos sincronizan tus workers, así que cien solicitudes que fallan juntas reintentan juntas, y la ráfaga sobrevive al backoff.
def backoff(attempt, base=1.0, cap=60.0):
ceiling = min(cap, base * (2 ** attempt))
return random.uniform(0, ceiling) # full jitter
Para la clase rate, el objetivo puede decirte exactamente cuánto esperar, y esa instrucción prevalece sobre tu propio calendario:
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
Uniéndolo todo
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)
Tres detalles ahí importan más que la estructura. La función lanza una excepción en errores terminales en lugar de devolver None, de modo que quien la llama no pueda confundir un fallo con datos vacíos. Los fallos de identidad reemplazan la sesión en lugar de reutilizarla, porque la anterior ya es conocida por el objetivo. Y los fallos de rate deliberadamente no tocan la sesión, manteniendo la misma ruta mientras se reduce el ritmo.
Salvaguardas que evitan que los reintentos se conviertan en el problema
La lógica por solicitud no basta por sí sola, porque no tiene visión del sistema. Tres añadidos hacen la mayor parte del trabajo de protección.
Un presupuesto de reintentos limita los reintentos como una proporción del tráfico total hacia un objetivo, digamos diez por ciento. En operación normal nunca te acercas a él. Cuando un objetivo falla ampliamente, el presupuesto se agota de inmediato y los reintentos adicionales simplemente no ocurren, que es el comportamiento deseado, ya que los reintentos ayudan con fallos aislados y perjudican activamente durante una interrupción general.
Un circuit breaker por objetivo detiene el envío por completo una vez que la tasa de fallo cruza un umbral, espera un periodo de enfriamiento, y luego deja pasar un goteo para probar la recuperación. Esto protege al objetivo de tu avalancha y protege tus direcciones de acumular fallos contra un sitio que no responde a nadie.
Un limitador de tasa compartido por el que también pasan los reintentos. Si los reintentos evitan tu control de ritmo, tu ruta de error se convierte en una inundación sin regular justo cuando el objetivo está menos capacitado para soportarla. Enruta cada intento a través del mismo limitador, según rate limiting and throttling.
Los tres pertenecen al componente que ya ve cada solicitud, que es el argumento a favor de a proxy manager en lugar de código de reintento por tarea.
Idempotencia y la cola de mensajes muertos
Dos preocupaciones prácticas que es fácil olvidar.
Los reintentos asumen que la operación es segura de repetir. Para la recolección esto es casi siempre cierto, ya que obtener una página dos veces cuesta ancho de banda y nada más. Si alguna parte de tu pipeline escribe como efecto secundario de obtener datos, haz que la escritura sea idempotente, indexada por algo estable, para que un reintento no cree un registro duplicado.
Y cuando una solicitud agota sus intentos, no la descartes. Envíala a una cola de mensajes muertos y vuelve a procesarla mucho más tarde, en la siguiente ejecución o tras un largo enfriamiento, en lugar de dentro de la ráfaga actual. La mayoría de los elementos que fallan durante un incidente tienen éxito por su cuenta una hora después, y una cola convierte un fallo duro en uno diferido sin coste alguno.
Vigila la tasa de reintentos
Los reintentos son la advertencia más temprana que obtienes. La proporción de reintentos, es decir, los reintentos como parte de las solicitudes por objetivo, sube antes de que la tasa de éxito baje, porque un pipeline que reintenta hasta llegar a un resultado que parece normal está ocultando un problema en lugar de resolverlo. También es puro coste en un producto facturado por ancho de banda, así que es a la vez una métrica de salud y una métrica de gasto. Ponla en el panel junto a la tasa de éxito validada, según proxy KPIs y monitoring your pipeline.
En resumen
La lógica de reintentos es un clasificador seguido de cuatro respuestas, no un bucle con un contador. Valida el cuerpo para que los bloqueos suaves se clasifiquen como fallos, luego espera ante las señales de rate sin rotar, retira la sesión en los fallos de identidad, aplica backoff con jitter en los transitorios, y nunca reintentes un error terminal. Limita los intentos y el retraso. Después añade las salvaguardas que una vista por solicitud no puede ofrecer: un presupuesto de reintentos para que una interrupción amplia no pueda convertirse en una inundación, un circuit breaker por objetivo, y un limitador compartido que los reintentos también respeten. Envía las solicitudes agotadas a una cola de mensajes muertos para una ejecución posterior, y vigila la proporción de reintentos como tu señal más temprana de que algo está cambiando.
La mitad de failover de todo esto, tener un lugar limpio al que reintentar, es lo que proporcionan los residential proxies: un gran pool de direcciones reales de tipo residencial para que una sesión retirada se reemplace en lugar de reutilizarse, con per-GB pricing que hace que los reintentos disciplinados sean directamente más baratos que los indisciplinados.