---
title: "Web scraping en Python con proxies rotativos"
description: "Apunta requests o httpx a un gateway rotativo, reintenta los 429 con backoff, limita la concurrencia y verifica la IP de salida. Código Python probado."
url: https://proxynet.io/es/blog/python-web-scraping-rotating-proxy
date: 2026-09-29
author: "Acar Diveroli"
category: "Web scraping, Tutoriales"
lang: es
---

# Web scraping en Python con proxies rotativos

Tu script lee 2.000 páginas de producto cada noche. Las primeras 300 vuelven bien, luego las respuestas se convierten en `429 Too Many Requests` y, unos minutos después, cada petición recibe un `403`. Nada cambió en el código; el sitio contó las peticiones desde tu dirección y decidió que ningún visitante se comporta así. Un proxy rotativo es la respuesta habitual, pero mal conectado crea problemas nuevos: la IP no cambia cuando lo esperas, un inicio de sesión se rompe a mitad de camino o veinte workers convierten un scraper educado en una avalancha.

Este tutorial monta el sistema en Python de principio a fin: por qué una sola IP acaba limitada, cómo un gateway rotativo asigna salidas, el diccionario `proxies` de requests, la reutilización de sesiones y por qué apaga la rotación sin avisar, reintentos que respetan `Retry-After`, un pool de hilos y una variante con httpx y asyncio, la elección entre por petición y sesión fija, la comprobación de la IP de salida y los límites que mantienes. Cada fragmento se ejecutó el 29 de septiembre de 2026 con Python 3.13.9, requests 2.34.2, httpx 0.28.1 y tenacity 9.1.4. Los conceptos están en [Qué es la rotación de IP](/es/blog/ip-rotation-explained) y [Cómo hacer scraping sin que te bloqueen](/es/blog/web-scraping-without-getting-blocked); este artículo es el código.

> **Nota: Respuesta breve**
>
> Pon la dirección del gateway en un diccionario `proxies` (`{"http": GATEWAY, "https": GATEWAY}`) y pásalo en cada `requests.get`, o entrégalo a `httpx.AsyncClient(proxy=...)`. Con rotación por petición cada conexión nueva sale por una IP distinta, así que no reutilices una `Session` para páginas que no deban compartir dirección; para las que sí deban compartirla, añade `-session-<id>-ttl-<seconds>` al nombre de usuario. Envuelve cada petición en un bucle de reintentos que duerma el `Retry-After` del sitio ante un `429` y retroceda exponencialmente ante errores de red, limita la concurrencia con `ThreadPoolExecutor(max_workers=4)` o un `asyncio.Semaphore`, y comprueba la IP de salida contra un servicio de eco antes de la primera ejecución real.

## ¿Por qué una sola IP recibe 429 o 403?

Los sitios cuentan peticiones por cliente, y el identificador de cliente más sencillo es la IP de origen. Un limitador mantiene un contador por dirección dentro de una ventana y, cuando el contador supera el umbral, responde con `429 Too Many Requests`. La [RFC 6585, sección 4](https://www.rfc-editor.org/rfc/rfc6585#section-4) define el código para este caso y dice que la respuesta puede llevar una cabecera `Retry-After` que indica cuánto esperar. Los algoritmos de conteo están en [Error 429 Too Many Requests explicado](/es/blog/http-429-too-many-requests).

Un `403` tras varios `429` es otra capa: el limitador te pidió frenar, no frenaste, y una regla movió tu dirección a una lista de bloqueo que sobrevive a tu script. Rotar salidas reparte las peticiones entre muchas direcciones para que ninguna cruce el umbral, pero no sustituye a frenar: envía 600 peticiones por minuto desde diez direcciones a un sitio que permite 60 y tendrás diez direcciones bloqueadas en lugar de una.

## ¿Cómo funciona un gateway de proxy rotativo?

En el modelo de gateway (backconnect) tu código conoce una sola dirección, `pr.proxynet.io:8000` en los ejemplos, y el proveedor gestiona el pool que hay detrás. Una petición HTTPS sigue este camino:

1. Tu cliente abre una conexión TCP al gateway y envía `CONNECT target:443` con una cabecera `Proxy-Authorization: Basic ...` construida a partir de `user:pass`.
2. El gateway lee el nombre de usuario. Todo lo que sigue a la parte de tu cuenta es un parámetro: `-country-tr` filtra el pool a un país, `-session-<id>-ttl-<seconds>` pide una sesión fija.
3. El gateway elige una salida del pool filtrado: una nueva para cada túnel en modo por petición, o la ya ligada a tu ID de sesión en modo fijo.
4. Se establece el túnel, TLS corre entre tu cliente y el destino, y el destino ve la IP de la salida.
5. Cuando el túnel se cierra, el vínculo desaparece. La siguiente conexión recibe otra salida, salvo que un ID de sesión la esté reteniendo.

El paso 3 es toda la historia de este tutorial. La unidad de rotación es el túnel, no la petición HTTP: mientras un túnel `CONNECT` siga abierto, cada petición que pasa por él sale por la misma salida. Por eso el keep-alive, que todo cliente moderno activa por defecto, cambia lo que "por petición" significa en la práctica. El lado del producto está en la página de [Proxies rotativos](https://proxynet.io/es/rotating-proxy); las salidas de aquí vienen de un pool de [Proxies residenciales](https://proxynet.io/es/residential-proxy), así que el destino ve una dirección doméstica común. El panel genera el nombre de usuario real (`proxynet-xxxxxxxx-country-tr-session-…-ttl-1800`); abajo se abrevia a `user`.

## Rotación por petición vs. sesión fija vs. IP estática

| | Rotación por petición | Sesión fija | IP estática |
|---|---|---|---|
| Qué cambia la IP | Cada conexión nueva | Que cambie el ID de sesión o venza el TTL (de 1 a 60 minutos) | Nada; la dirección es tuya |
| Nombre de usuario | `user` | `user-session-<id>-ttl-<seconds>` | Producto aparte, `host:port` fijo |
| Cookies e inicios de sesión | Se rompen; el sitio ve una cookie desde muchas ciudades | Se conservan durante el TTL | Se conservan indefinidamente |
| Costo por petición | Handshake TCP y TLS nuevo cada vez | Un handshake, luego keep-alive | Un handshake, luego keep-alive |
| Workers en paralelo | Cada conexión recibe su propia salida | Un ID por worker, una salida por ID | Tantas salidas como direcciones alquiles |
| Trabajo típico | Páginas de producto o listados independientes | Login, carrito, paginación de varias páginas | Listas blancas de API, cuentas de larga duración |

La sesión fija es una petición de retener la salida, no una garantía; un dispositivo residencial puede desconectarse y el gateway mueve la sesión antes de tiempo. El comportamiento del producto está en la página de [Proxies de sesión fija](https://proxynet.io/es/sticky-proxy).

## Configurar requests: el diccionario proxies

La [documentación de requests sobre proxies](https://requests.readthedocs.io/en/latest/user/advanced/#proxies) recomienda pasar `proxies` de forma explícita en cada petición, porque los valores fijados en `session.proxies` pueden quedar anulados por las variables de entorno `HTTP_PROXY` y `HTTPS_PROXY`. Las claves son el esquema de la URL de destino, y ambas apuntan a la misma dirección `http://` del gateway, ya que el tráfico HTTPS se tuneliza:

```python
import os
import requests

# Esta cadena la genera el panel; guárdala en una variable de entorno, no en git.
GATEWAY = os.environ.get("PROXY_URL", "http://user:pass@pr.proxynet.io:8000")
PROXIES = {"http": GATEWAY, "https": GATEWAY}
HEADERS = {"User-Agent": "price-monitor/1.0 (+mailto:you@example.com)"}

resp = requests.get("https://httpbin.org/ip", proxies=PROXIES, headers=HEADERS, timeout=(5, 30))
print(resp.status_code, resp.json())
```

La tupla `timeout` fija 5 segundos para conectar y 30 para leer; sin ella, una salida atascada deja colgado un worker para siempre. El `User-Agent` nombra tu script y da al dueño del sitio una forma de contactarte, lo que [Qué es un user agent](/es/blog/what-is-user-agent) recomienda frente a fingir ser un navegador. Las credenciales viven en una variable de entorno porque la misma documentación advierte contra los archivos bajo control de versiones. Para SOCKS5 instala `requests[socks]` y usa `socks5h://user:pass@pr.proxynet.io:1080`; la `h` hace que el proxy resuelva el DNS, y el puerto SOCKS5 rota de la misma forma.

## Reutilizar la sesión: por qué la IP no cambió

Una `requests.Session` reutiliza la conexión TCP, lo que detrás de un proxy significa que reutiliza el túnel y, por tanto, la salida. Lo medimos con un proxy de prueba local que registra cada `CONNECT`:

| Tres `GET` al mismo host | Túneles abiertos | Salidas asignadas (una por túnel) |
|---|---|---|
| `requests.get(...)` tres veces, sin sesión | 3 | 3 |
| Una `Session`, tres `s.get(...)` | 1 | 1 |
| Una `Session`, `headers={"Connection": "close"}` | 3 | 3 |
| Un `httpx.AsyncClient`, tres `await client.get(...)` secuenciales | 1 | 1 |

La regla se deduce sola: para rotación por petición, llama a `requests.get` sin sesión o envía `Connection: close`; para trabajo con sesión fija, usa una `Session` y un ID de sesión juntos, porque de lo contrario una conexión caída reasignaría la salida.

## Reintentos con backoff que respetan Retry-After

Detrás de un gateway rotativo fallan dos cosas: la red (una salida se cae, el túnel se rechaza, una lectura agota el tiempo) y el destino (un `429` o un `5xx`). Ambas merecen un reintento, pero no la misma espera. Un `429` puede traer el número propio del sitio en `Retry-After`, y ese número gana. Todo lo demás recibe backoff exponencial con jitter, para que cuatro workers que fallaron juntos no reintenten juntos:

```python
import logging
import random
import time

import requests

MAX_ATTEMPTS = 4         # primer intento + 3 reintentos
RETRY_STATUS = {429, 500, 502, 503, 504}
TIMEOUT = (5, 30)        # conexión, lectura - segundos
log = logging.getLogger("scraper")

def backoff(attempt: int) -> float:
    """1, 2, 4, 8 s ... más jitter para que los workers no reintenten al unísono."""
    return min(2 ** (attempt - 1), 30) + random.uniform(0, 1)

def fetch(url: str) -> requests.Response:
    """GET de una URL con reintentos. Lanza excepción tras el último intento fallido."""
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            resp = requests.get(url, proxies=PROXIES, headers=HEADERS, timeout=TIMEOUT)
        except (requests.ConnectionError, requests.Timeout) as exc:
            # Incluye ProxyError: el gateway rechazó o cortó el túnel.
            log.warning("attempt %d/%d %s: %s", attempt, MAX_ATTEMPTS, url, exc.__class__.__name__)
            if attempt == MAX_ATTEMPTS:
                raise
            time.sleep(backoff(attempt))
            continue

        if resp.status_code not in RETRY_STATUS:
            return resp  # 200, 404, 301 ... que decida quien llama

        # El sitio nos pidió frenar. Su número gana sobre nuestro calendario.
        retry_after = resp.headers.get("Retry-After")
        wait = float(retry_after) if retry_after and retry_after.isdigit() else backoff(attempt)
        log.warning("attempt %d/%d %s: HTTP %d, sleeping %.1fs", attempt, MAX_ATTEMPTS, url, resp.status_code, wait)
        if attempt == MAX_ATTEMPTS:
            resp.raise_for_status()
        time.sleep(wait)
    raise RuntimeError("unreachable")
```

`requests.ProxyError` es una subclase de `ConnectionError`, así que un gateway que responde al `CONNECT` con un error se reintenta por un túnel nuevo, lo que en modo por petición significa una salida nueva. Un `404` se devuelve tal cual: la página ya no está, y otra dirección no la va a traer de vuelta.

La misma política como decorador de `tenacity` es más corta, a cambio de convertir `Retry-After` en una excepción, de modo que se aplica el calendario de tenacity en lugar de la cabecera:

```python
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter

class RetryableStatus(Exception):
    """Se lanza en 429 y 5xx para que tenacity los reintente como un error de red."""

@retry(
    stop=stop_after_attempt(4),
    wait=wait_exponential_jitter(initial=1, max=20),
    retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout, RetryableStatus)),
    reraise=True,
)
def fetch_with_tenacity(url: str) -> requests.Response:
    resp = requests.get(url, proxies=PROXIES, headers=HEADERS, timeout=TIMEOUT)
    if resp.status_code in RETRY_STATUS:
        raise RetryableStatus(f"HTTP {resp.status_code} for {url}")
    return resp
```

Contra `https://httpbin.org/status/503` reintentó a los 1,5, 2,8 y 4,8 segundos y luego lanzó `RetryableStatus`.

## Un scraper completo: pool de hilos, reintentos y registro

`ThreadPoolExecutor` ejecuta cuatro llamadas a `fetch` a la vez, `as_completed` entrega los resultados según terminan, y una página que falla se registra sin detener la ejecución. Guárdalo como `scrape.py`:

```python
"""scrape.py - descarga una lista de páginas a través de un gateway de proxy rotativo."""
import logging
import os
import random
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests

GATEWAY = os.environ.get("PROXY_URL", "http://user:pass@pr.proxynet.io:8000")
PROXIES = {"http": GATEWAY, "https": GATEWAY}
HEADERS = {"User-Agent": "price-monitor/1.0 (+mailto:you@example.com)"}

MAX_WORKERS = 4          # peticiones en vuelo al mismo tiempo
MAX_ATTEMPTS = 4
RETRY_STATUS = {429, 500, 502, 503, 504}
TIMEOUT = (5, 30)

log = logging.getLogger("scraper")

def backoff(attempt: int) -> float:
    return min(2 ** (attempt - 1), 30) + random.uniform(0, 1)

def fetch(url: str) -> requests.Response:
    # el bucle de reintentos de la sección anterior, sin cambios
    ...

def scrape(url: str) -> dict:
    started = time.monotonic()
    resp = fetch(url)
    return {
        "url": url,
        "status": resp.status_code,
        "bytes": len(resp.content),
        "seconds": round(time.monotonic() - started, 2),
    }

def main(urls: list[str]) -> None:
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    results, failed = [], []
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
        futures = {pool.submit(scrape, url): url for url in urls}
        for future in as_completed(futures):
            url = futures[future]
            try:
                row = future.result()
                results.append(row)
                log.info("ok   %s -> %d in %.1fs", url, row["status"], row["seconds"])
            except Exception as exc:  # una página mala no debe detener la ejecución
                failed.append((url, repr(exc)))
                log.error("fail %s -> %s", url, exc)
    log.info("done: %d ok, %d failed", len(results), len(failed))
    for row in results:
        print(row)

if __name__ == "__main__":
    targets = sys.argv[1:] or [f"https://httpbin.org/ip?n={i}" for i in range(8)]
    main(targets)
```

Lo ejecutamos a través de un proxy de prueba local que rechaza uno de cada tres túneles con `429` para ejercitar la ruta de reintento. Ocho URL, cuatro workers, ocho túneles, tres fallos inyectados, cero páginas perdidas:

```text
03:32:27 WARNING attempt 1/4 https://httpbin.org/ip?n=2: ProxyError
03:32:28 INFO ok   https://httpbin.org/ip?n=3 -> 200 in 1.1s
03:32:28 INFO ok   https://httpbin.org/ip?n=1 -> 200 in 1.1s
03:32:28 INFO ok   https://httpbin.org/ip?n=0 -> 200 in 1.1s
03:32:28 WARNING attempt 1/4 https://httpbin.org/ip?n=5: ProxyError
03:32:29 INFO ok   https://httpbin.org/ip?n=4 -> 200 in 0.9s
03:32:29 WARNING attempt 1/4 https://httpbin.org/ip?n=7: ProxyError
03:32:29 INFO ok   https://httpbin.org/ip?n=6 -> 200 in 0.9s
03:32:30 INFO ok   https://httpbin.org/ip?n=2 -> 200 in 2.6s
03:32:31 INFO ok   https://httpbin.org/ip?n=5 -> 200 in 2.4s
03:32:31 INFO ok   https://httpbin.org/ip?n=7 -> 200 in 2.0s
03:32:31 INFO done: 8 ok, 0 failed
```

El registro forma parte del diseño: URL, número de intento, estado o clase de excepción y la espera en cada línea bastan para distinguir "el sitio nos está limitando" de "el gateway está cortando túneles" sin volver a ejecutar nada. Cuatro workers es deliberadamente poco; la concurrencia multiplica tu tasa de peticiones, y la tasa es lo que mide el destino. Escribe las filas de `results` como muestra [Guardar datos de scraping en CSV, JSON y SQLite](/es/blog/save-scraped-data-csv-json-sqlite); el paso de parseo entre `resp.text` y una fila está en [Cómo extraer datos de un sitio web](/es/blog/extract-data-from-website).

## El mismo trabajo con httpx y asyncio

httpx recibe el proxy en el cliente, según la [documentación de proxies de httpx](https://www.python-httpx.org/advanced/proxies/). Un `Semaphore` sustituye al pool de hilos como tope de concurrencia; el bucle de reintentos conserva su forma, solo los sleeps pasan a ser `await`:

```python
import asyncio
import random

import httpx

MAX_IN_FLIGHT = 4

async def fetch(client: httpx.AsyncClient, url: str) -> httpx.Response:
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            resp = await client.get(url)
        except (httpx.TransportError, httpx.ProxyError) as exc:
            log.warning("attempt %d/%d %s: %s", attempt, MAX_ATTEMPTS, url, exc.__class__.__name__)
            if attempt == MAX_ATTEMPTS:
                raise
            await asyncio.sleep(2 ** (attempt - 1) + random.uniform(0, 1))
            continue
        if resp.status_code not in RETRY_STATUS:
            return resp
        retry_after = resp.headers.get("Retry-After")
        wait = float(retry_after) if retry_after and retry_after.isdigit() else 2 ** (attempt - 1) + random.uniform(0, 1)
        log.warning("attempt %d/%d %s: HTTP %d, sleeping %.1fs", attempt, MAX_ATTEMPTS, url, resp.status_code, wait)
        if attempt == MAX_ATTEMPTS:
            resp.raise_for_status()
        await asyncio.sleep(wait)
    raise RuntimeError("unreachable")

async def main(urls: list[str]) -> None:
    gate = asyncio.Semaphore(MAX_IN_FLIGHT)
    async with httpx.AsyncClient(proxy=GATEWAY, headers=HEADERS, timeout=httpx.Timeout(30, connect=5)) as client:

        async def one(url: str):
            async with gate:
                resp = await fetch(client, url)
                return {"url": url, "status": resp.status_code}

        rows = await asyncio.gather(*(one(u) for u in urls), return_exceptions=True)
    for url, row in zip(urls, rows):
        print("FAIL" if isinstance(row, Exception) else "ok  ", url, row)

asyncio.run(main([f"https://httpbin.org/ip?n={i}" for i in range(8)]))
```

Un cliente significa un pool de conexiones, y las conexiones del pool reutilizan túneles: con cuatro peticiones en vuelo y ocho URL, nuestra ejecución abrió cuatro túneles, así que pares de páginas compartieron salida. Si cada página debe salir por su propia dirección, envía `headers={"Connection": "close"}`; si un grupo de páginas debe compartir una, eso es una sesión fija, no un accidente del pool. Qué biblioteca encaja con cada trabajo está en [HTTPX vs. Requests vs. AIOHTTP](/es/blog/httpx-vs-requests-vs-aiohttp).

## Elegir por petición o sesión fija, y verificar la IP de salida

Decide por trabajo. Las páginas de producto, los listados de búsqueda y los perfiles públicos son independientes: rotación por petición, sin objeto de sesión. Un login seguido de veinte páginas paginadas es una sola conversación: una `Session` para las cookies, un ID de sesión para la salida, un worker. Un tarro de cookies que se mantiene mientras la IP cambia es la forma más común de acabar desconectado a mitad de ejecución; el lado del login está construido en [Sesiones y cookies en Python](/es/blog/python-login-session-cookies).

Antes de confiar en cualquiera de los dos modos, pregunta a un servicio de eco qué dirección ve: tres llamadas sin sesión deberían devolver tres direcciones distintas, tres con el mismo ID de sesión la misma:

```python
import uuid
import requests

HOST = "pr.proxynet.io:8000"
USER, PASSWORD = "user", "pass"
ECHO = "https://httpbin.org/ip"

def proxies_for(username: str) -> dict:
    url = f"http://{username}:{PASSWORD}@{HOST}"
    return {"http": url, "https": url}

print("per request:")
for _ in range(3):  # sin Session, así que cada llamada abre un túnel nuevo
    print("  ", requests.get(ECHO, proxies=proxies_for(USER), timeout=20).json()["origin"])

sid = uuid.uuid4().hex[:8]
sticky = proxies_for(f"{USER}-session-{sid}-ttl-600")  # misma salida hasta 600 s
print(f"sticky {sid}:")
with requests.Session() as s:
    for _ in range(3):
        print("  ", s.get(ECHO, proxies=sticky, timeout=20).json()["origin"])
```

Ejecútalo al inicio de un trabajo y registra el resultado. Si "per request" imprime la misma dirección tres veces, mira primero la reutilización de conexiones; si "sticky" imprime tres distintas, el ID de sesión no está llegando al gateway, normalmente porque un paso de codificación de URL estropeó el nombre de usuario.

## Límites de peticiones, robots.txt y los límites que mantienes

Un proxy rotativo cambia qué dirección ve el sitio, no lo que el sitio permite:

- **Lee robots.txt primero.** `urllib.robotparser` responde `can_fetch(user_agent, url)` en tres líneas; una ruta no permitida se queda fuera de la lista de URL. Lo que el archivo puede expresar está en [Qué es robots.txt](/es/blog/robots-txt).
- **Toma `Retry-After` al pie de la letra.** El bucle de reintentos duerme el número del sitio incluso cuando la rotación te dejaría seguir desde otra salida.
- **Limita la tasa, no solo los workers.** Cuatro workers sin retardo aún pueden enviar 40 peticiones por segundo a un sitio rápido; añade un pequeño `time.sleep` por worker si el sitio publica un límite.
- **Prefiere la API oficial** cuando exista; es más barata para ambas partes que parsear HTML a través de un proxy.
- **Nada de servicios para resolver CAPTCHA ni plugins para evadir la detección.** Un CAPTCHA significa que el sitio quiere a una persona; la respuesta es una tasa más baja, la API o pedir acceso.

## Dónde se usa este montaje

- **Monitoreo de precios.** Miles de páginas de producto independientes al día, rotación por petición, de cuatro a ocho workers; el dimensionado del pool está en la página de [extracción de datos](/es/data-scraping).
- **Paneles propios con login.** Una sesión fija por cuenta, un worker, cookies persistidas entre ejecuciones como en [Sesiones y cookies en Python](/es/blog/python-login-session-cookies).
- **Sustituir una lista de proxies hecha a mano.** Rotar una lista en código, como en [Cómo rotar proxies en Python](/es/blog/how-to-rotate-proxies-in-python), es el enfoque antiguo; el gateway elimina la lista y la contabilidad de direcciones muertas.

## Errores comunes

- **Reutilizar una `Session` y esperar una IP nueva por página.** Un túnel, una salida. Quita la sesión o envía `Connection: close`.
- **Un ID fijo sin `Session`.** La salida queda anclada, pero cada llamada paga un handshake nuevo y las cookies se pierden. Usa ambos juntos.
- **Reintentar un `404`.** La página ya no está; otra salida no la va a encontrar. Reintenta solo `429`, `5xx` y errores de red.
- **Sin timeout.** Una salida atascada bloquea un worker hasta que se mata el proceso. Pasa siempre `timeout=(5, 30)`.
- **Credenciales en el script.** Acaban en el historial de git. Léelas del entorno.

## Guía de decisión

| Necesidad | Recomendación |
|---|---|
| Muchas páginas independientes, sin login | Rotación por petición, `requests.get` sin sesión, 4 workers para empezar |
| Login y luego muchas páginas | Sesión fija (`-session-<id>-ttl-<seconds>`) más una `requests.Session`, un worker por cuenta |
| Miles de peticiones pequeñas, limitadas por E/S | `AsyncClient` de httpx con un `Semaphore`; `Connection: close` si cada una necesita su propia salida |
| El sitio publica un límite de peticiones | Primero ajusta workers y retardos por debajo de ese límite, después la rotación |
| Precios por país | `-country-xx` en el nombre de usuario, rotación por petición dentro de ese país |
| La dirección nunca debe cambiar (lista blanca de API) | No es un producto rotativo; usa una IP estática |

## Preguntas frecuentes

### ¿Por qué la IP no cambia en cada petición?

Porque tu cliente reutiliza la conexión. La `Session` de requests y el `Client` de httpx mantienen abierta la conexión TCP, y por tanto el túnel del proxy, entre peticiones; la salida se elige al abrir el túnel. Usa llamadas `requests.get` sueltas o una cabecera `Connection: close` para tener una salida nueva por petición.

### ¿Cómo uso una sesión fija en Python?

Añade `-session-<id>-ttl-<seconds>` al nombre de usuario en la URL del proxy, conserva el mismo ID durante toda la conversación y haz las llamadas a través de una única `requests.Session` para que se reutilicen las cookies y el túnel. El TTL puede ir de 1 a 60 minutos; elige un valor mayor que la duración del trabajo.

### ¿Debo usar requests o httpx para hacer scraping con proxies?

Para unos cientos de páginas por noche, requests con un pool de hilos basta y es más fácil de depurar. Para decenas de miles de peticiones pequeñas, el cliente asíncrono de httpx con un `Semaphore` consume menos recursos. Ambos aceptan la misma URL de gateway y la misma lógica de reintentos.

### ¿Cuántos workers concurrentes son seguros?

Empieza con cuatro y observa las respuestas. Lo que importa son las peticiones por minuto tal como las ve el destino, así que la respuesta depende del límite del sitio, no del tamaño del pool. Si aparecen `429` con cuatro, baja el número o añade un retardo.

### ¿Un proxy rotativo evita un 429?

Reparte las peticiones entre más direcciones para que cada una se mantenga bajo el umbral; no elimina el umbral. Si el sitio fijó un límite, respeta `Retry-After`, baja la tasa o usa la API. Rotar más rápido para pasar por encima de un `429` es la forma en que las direcciones acaban en listas de bloqueo.

### ¿Cómo verifico que el proxy funciona?

Pide un endpoint de eco de IP como `https://httpbin.org/ip` a través del proxy y compara la respuesta con tu propia dirección: tres veces sin sesión para ver la rotación, tres veces con un ID de sesión para ver la persistencia.

## En resumen

Un proxy rotativo en Python es una URL de gateway en un diccionario `proxies`, un bucle de reintentos que respeta `Retry-After`, un tope de unos pocos workers y una regla clara sobre cuándo una página puede compartir salida con la anterior. Mantén juntos el objeto de sesión y el ID de sesión para el trabajo con login, mantén a ambos lejos de las páginas independientes y comprueba la IP de salida antes de la primera ejecución real. El pool detrás del gateway está en la página de [proxy](/es/proxy).
