“Balanceo de carga de proxies” es una frase que significa algo muy distinto a lo que solía. En el modelo antiguo comprabas una lista de IPs y construías un rotador: elige una IP, rastrea su salud, sácala cuando muera, vuelve a meterla más tarde. Los equipos aún escriben ese middleware por costumbre, y en un gateway moderno es en gran parte código muerto.
En un gateway de proxies residenciales, el pool se balancea solo. Un endpoint, y el proveedor asigna una salida de entre millones de IPs por petición o por sesión, del lado del servidor. No estás balanceando IPs. Lo que sí estás arquitectando es la capa de encima: cómo el trabajo se mapea a identidades, cuánta concurrencia tolerará un objetivo, y qué pasa cuando una petición falla. Acierta esas tres y el sistema escala; fállalas y ninguna cantidad de calidad de proxy te salva.
Este es un deep-dive sobre esas tres capas, para equipos que corren pipelines de recolección en producción.
Qué hace el gateway, y dónde empieza tu arquitectura
Vale la pena ser preciso sobre la división, porque determina lo que deberías y no deberías construir:
El gateway maneja: la selección de la IP de salida del pool, la coincidencia geo, la salud de las IPs individuales, y la mecánica de rotación. Tú expresas la intención a través del nombre de usuario (country, city, sid, ttl) y él resuelve eso a una salida.
Tú manejas: la unidad de identidad (qué recibe su propia IP, y por cuánto tiempo), el control de concurrencia (cuán fuerte empujas cada objetivo), la política de reintentos (qué pasa al fallar), y la observabilidad.
El error arquitectónico más común es construir una capa rotadora de proxies que duplica el gateway. Si te encuentras manteniendo una tabla de salud de IPs, para, ese es el trabajo del proveedor, y tu versión tiene una vista peor del pool que la suya. Tu trabajo empieza al nivel de política de peticiones.
Capa 1: arquitectura de rotación, elegir la unidad de identidad
Esta es la decisión de diseño de la que cuelga todo lo demás. La pregunta no es “¿con qué frecuencia debería rotar?” sino “¿qué es una identidad, y qué trabajo le pertenece?”
Las dos primitivas:
- Rotación por petición (omite
sid): cada petición recibe una IP de salida fresca. Máxima diversidad de IP, cero reutilización de conexiones, ideal para grandes conjuntos de fetches independientes donde ninguna petición depende de la anterior. - Sesión sticky (
sid+ttl): todas las peticiones que llevan ese id de sesión comparten una IP de salida hasta que expire el TTL. Necesaria cuando un flujo debe parecer un usuario, y es también lo que hace reutilizables las conexiones agrupadas (ve sticky vs rotating).
La regla de diseño: rota en el límite de una unidad lógica de trabajo, no arbitrariamente. Una unidad de trabajo es lo que deba ser internamente consistente, las reseñas paginadas de un producto, un flujo de búsqueda, la sesión de una cuenta. Mapéalo limpiamente:
un worker = una unidad de trabajo = un sid = una IP de salida (durante su TTL)Deriva el id de sesión de la unidad de trabajo en lugar de aleatoriamente, para que el comportamiento sea reproducible y puedas decidir deliberadamente si un reintento mantiene o cambia la identidad:
def session_id(job_id: str, attempt: int = 0) -> str: # Mismo job → misma identidad. Sube `attempt` para obtener deliberadamente una IP nueva. return f"{job_id}-a{attempt}"
def proxy_for(job_id, attempt=0, country="us", ttl=600): sid = session_id(job_id, attempt) user = f"{USER}-country-{country}-sid-{sid}-ttl-{ttl}" url = f"http://{user}:{PASS}@p.shifter.io:443" return {"http": url, "https": url}Ese único parámetro attempt está haciendo trabajo arquitectónico real: convierte “reintentar con una identidad distinta” en una operación de primera clase e intencional en lugar de un accidente.
Capa 2: arquitectura de concurrencia, y por qué es por host
La restricción sobre la concurrencia casi nunca es tu plan de proxy (las conexiones concurrentes típicamente no son el límite). Es lo que el objetivo tolera. Lo que significa que un único límite global de concurrencia tiene la forma equivocada: deja que un objetivo permisivo mate de hambre a uno frágil, y deja que uno frágil frene todo tu pipeline.
Limita por host, no globalmente:
import asynciofrom collections import defaultdict
# Un semáforo por host objetivo, ajustado a lo que ese host tolera_limits = {"tough-site.example": 4, "open-site.example": 32}_sems = defaultdict(lambda: asyncio.Semaphore(8)) # default sensatofor host, n in _limits.items(): _sems[host] = asyncio.Semaphore(n)
async def fetch(client, url, host): async with _sems[host]: # backpressure aplicada por host return await client.get(url, timeout=30)Dos propiedades que esto te compra. Aislamiento: un objetivo lento u hostil no puede consumir todos los workers. Ajustabilidad: puedes subir la concurrencia en objetivos a los que no les importa y bajarla en el que bloquea, de forma independiente.
Y resiste el impulso de subir los números. Pasada la tolerancia de un objetivo, el paralelismo extra no compra throughput, compra bloqueos, y los bloqueos te cuestan más en reintentos de lo que la concurrencia ganó jamás (reducir la latencia cubre ese intercambio).
Capa 3: arquitectura de reintentos, la parte que lo hace o lo rompe
La mayoría de los pipelines fallan aquí. Un ingenuo for attempt in range(3): retry() convierte una mala tarde en una tormenta de reintentos que amplifica los bloqueos y quema ancho de banda. Una capa de reintentos de verdad hace cuatro cosas.
1. Clasifica antes de reintentar. No todos los fallos son iguales, y la respuesta es distinta para cada uno:
| Fallo | Ejemplo | Respuesta correcta |
|---|---|---|
| Transporte | timeout, connection reset | Reintenta, la misma identidad está bien |
| Rate limit | 429 | Back off fuerte, ralentiza todo el host |
| Bloqueo | 403, CAPTCHA, bloqueo blando con 200 | Reintenta con una identidad nueva, no reutilices la IP |
| Permanente | 404, 400 | No reintentes, registra y sigue |
Nota el bloqueo blando: un 200 con una página de CAPTCHA es un fallo. Si clasificas solo por código de estado, contarás bloqueos como éxitos y tu pipeline se llenará en silencio de basura (por qué se bloquean los scrapers).
2. Aplica backoff con jitter. El backoff exponencial sin jitter sincroniza a tus workers en una estampida que golpea al objetivo en oleadas. Añade siempre aleatoriedad.
3. Cambia de identidad en los bloqueos. Reintentar un bloqueo en la misma IP sticky es simplemente pedir el mismo rechazo otra vez. Sube el contador de intentos para que el id de sesión cambie y el gateway te entregue una salida distinta.
import random, asyncio
async def fetch_with_policy(client, url, job_id, host, max_attempts=4): for attempt in range(max_attempts): proxies = proxy_for(job_id, attempt=attempt) # identidad nueva por intento try: r = await client.get(url, proxies=proxies, timeout=30) kind = classify(r) # ok | ratelimit | block | permanent if kind == "ok": return r if kind == "permanent": return None # no gastes intentos if kind == "ratelimit": await slow_down(host) # ensancha el ritmo del host except (asyncio.TimeoutError, ConnectionError): pass # transporte: reintenta backoff = (2 ** attempt) + random.uniform(0, 1) # jitter, siempre await asyncio.sleep(backoff) await dead_letter(job_id, url) # apárcalo, no bloquees el pipeline return None4. Ríndete con elegancia. Tras N intentos, aparca el ítem en una cola de dead-letter y sigue. Los reintentos ilimitados no rescatan un trabajo; convierten un fallo en carga sostenida contra un objetivo que ya te está rechazando.
Dos patrones más que vale la pena construir cuando estás a escala: un circuit breaker por host (si la tasa de éxito se desploma, deja de enviar un rato en lugar de moler), y ítems de trabajo idempotentes, para que un reintento sea siempre seguro de correr.
La forma de referencia
Junto, el pipeline se ve así:
cola de trabajo ↓limitador por host (semáforo + ritmo) ↓worker → id de sesión (sid) → gateway → objetivo ↓clasifica la respuesta ├── ok → valida contenido → emite ├── ratelimit → ralentiza host → backoff + reencola ├── bloqueo → identidad nueva → backoff + reencola └── permanente → registra, descarta ↓ (tras N intentos)cola de dead-letterCada flecha es una decisión que de otro modo tomarías implícitamente y mal. Hecha explícita, el sistema se degrada con elegancia en lugar de colapsar.
Observabilidad: no puedes operar esto a ciegas
Instrumenta como mínimo: tasa de éxito por host (con validación de contenido, no códigos de estado), latencia p50/p95, tasa de reintentos y desglose por tipo de fallo, y bytes por registro exitoso. Esos cuatro te dicen dónde actuar, una tasa de reintentos creciente en un host significa apretar la concurrencia de ese host; unos bytes-por-registro crecientes significan que estás trayendo payload que no parseas (recortar los costes de ancho de banda). Los métodos de medición están en cómo probar la velocidad, tasa de éxito, y precisión de ubicación de un proxy.
Antipatrones a evitar
- Construir un rotador de IPs encima de un gateway. Redundante, y peor informado que el propio balanceo del proveedor.
- Un único límite global de concurrencia. Acopla objetivos no relacionados; limita por host.
- Reintentar bloqueos en la misma IP sticky. Misma IP, misma respuesta.
- Backoff sin jitter. Sincroniza a los workers en oleadas.
- Reintentos ilimitados. Amplifica los bloqueos, quema ancho de banda, no retrasa nada más que lo inevitable.
- Tratar 200 como éxito. Los bloqueos blandos son 200s; valida el contenido o tus datos se pudren en silencio.
- Rotar a mitad de flujo. Cambiar de IP dentro de una sesión de varios pasos es una señal de detección, rota en los límites de unidad.
Preguntas frecuentes
¿Necesito construir el balanceo de carga de proxies yo mismo? No al nivel de IP. Un gateway asigna salidas del pool del lado del servidor, así que un rotador de IPs o una tabla de salud de tu lado es redundante. Arquitecta la capa de encima: unidades de identidad, concurrencia por host, y política de reintentos.
¿Cuántas peticiones concurrentes debería correr? Depende del objetivo, no de tu plan. Pon un límite por host basado en lo que ese host tolera, y ajusta cada uno de forma independiente. Pasado ese punto, más paralelismo produce bloqueos, no throughput.
¿Un reintento debería usar la misma IP o una nueva? Depende del fallo. Los errores de transporte (timeouts) pueden reintentar con la misma identidad. Los bloqueos y CAPTCHAs deberían reintentar con una identidad nueva, cambiando el id de sesión, ya que la IP es lo que fue rechazado.
¿Cómo evito que los reintentos empeoren el bloqueo? Clasifica los fallos, aplica backoff exponencial con jitter, limita los intentos, manda a dead-letter lo que no vaya a tener éxito, y añade un circuit breaker por host. Los reintentos ilimitados y sin clasificar son cómo un problema pequeño de bloqueo se vuelve uno grande.
¿Qué debería monitorizar en un pipeline de proxies? Tasa de éxito por host (con validación de contenido), latencia p50/p95, tasa de reintentos con desglose por tipo de fallo, y bytes por registro exitoso. Esos cuatro sacan a la superficie casi todo problema que valga la pena arreglar.
En resumen
En un gateway, balancear la carga del pool no es tu problema, y fingir que lo es lleva a los equipos a construir lo equivocado. La arquitectura que de verdad determina si un pipeline de recolección escala vive en tres decisiones: qué constituye una identidad y qué trabajo le pertenece, cuánta concurrencia tolera cada objetivo individual, y cómo los fallos se clasifican, se les aplica backoff, se les cambia identidad, y finalmente se abandonan. Acierta esas, instruméntalas, y el pipeline se degrada con elegancia bajo presión en lugar de caerse.
El gateway maneja el resto. Si estás construyendo esto, el gateway residencial te da rotación y sesiones sticky a través del nombre de usuario para que tu arquitectura se quede en tu código, no en una capa de gestión de proxies, y la calidad del pool determina con qué frecuencia esa ruta de reintento se ejercita siquiera (reputación de IP). Para consideraciones a gran escala, ve la mejor red de proxies residenciales para scraping a gran escala, y la página de precios tiene los planes por GB para probar el diseño contra tu propia carga de trabajo.