Integración8 min de lectura

Proxies en Scrapy 2.19: autenticación, rotación, errores

Configura proxies en Scrapy 2.19: credenciales en meta["proxy"], rotación por petición, HTTP_PROXY y soluciones para TunnelError 407, el 407 ignorado y socks5.

En esta página

Pon las credenciales en la URL del proxy y pásala como meta["proxy"]:

python
import scrapy

PROXY = "http://user:pass@proxy.example.com:8080"


class ExampleSpider(scrapy.Spider):
    name = "example"

    async def start(self):
        yield scrapy.Request("https://example.com/", meta={"proxy": PROXY})

    def parse(self, response):
        self.logger.info("%s via %s", response.status, response.meta["proxy"])

HttpProxyMiddleware está activado por defecto (HTTPPROXY_ENABLED = True). Traslada el usuario y la contraseña de la URL a una cabecera Proxy-Authorization (documentación), así que response.meta["proxy"] contiene la URL sin credenciales.

En nuestro laboratorio esto autenticó peticiones https:// y http:// contra un proxy local que exige autenticación Basic. Todo lo que sigue se registró el 7 de octubre de 2026 con Scrapy 2.19.0, Twisted 26.4.0 y Python 3.14.4.

Tres detalles antes de los errores:

  • Codifica la contraseña con porcentajes. p@ss:w/rd funcionó como p%40ss%3Aw%2Frd. Sin codificar, la petición falló con ValueError: Port could not be cast to integer value as 'w'.
  • Las peticiones creadas en un callback no heredan el proxy. Una petición creada con response.follow() salió directa. Una redirección conservó el proxy. Define meta["proxy"] en cada petición, o en un downloader middleware como se muestra más abajo.
  • Usa async def start(). Un spider que solo definía start_requests() se abrió, no envió nada y se cerró con finish_reason: finished y sin ningún aviso. Scrapy 2.16.0 eliminó el método: «is removed and no longer called» (notas de la versión). Lo sustituye Spider.start(). Scrapy no llama a start_requests() explica la migración, incluidos los spider middlewares.

Rotación de proxies: define meta["proxy"] en cada petición

La rotación es un downloader middleware que se ejecuta antes que HttpProxyMiddleware (orden 750) y fija una URL de proxy con credenciales:

python
from itertools import cycle

PROXIES = cycle([
    "http://user:pass@proxy-a.example.com:8080",
    "http://user:pass@proxy-b.example.com:8080",
])


class RotateProxyMiddleware:
    def process_request(self, request):
        request.meta["proxy"] = next(PROXIES)
python
DOWNLOADER_MIDDLEWARES = {"myproject.middlewares.RotateProxyMiddleware": 740}

En el laboratorio, una petición redirigida pasó dos veces por este middleware, fue a dos proxies y se autenticó en ambos.

La versión que falla fija una URL sin credenciales y escribe la cabecera a mano:

python
request.meta["proxy"] = next(PROXIES)  # "http://proxy-a.example.com:8080"
request.headers["Proxy-Authorization"] = "Basic dXNlcjpwYXNz"

La primera petición llegó al proxy A con la cabecera. El destino respondió 301 y la petición redirigida volvió a pasar por los middlewares. La rotación eligió el proxy B y puso la cabecera otra vez, pero el proxy B recibió la petición sin Proxy-Authorization y respondió 407. Una petición https:// reintentada perdió la cabecera de la misma forma.

HttpProxyMiddleware registra con qué URL de proxy se envió una cabecera puesta a mano. Cuando una pasada posterior sobre la misma petición tiene otra URL de proxy sin credenciales, borra la cabecera. Las notas de la versión 2.6.2 explican el motivo: los middlewares de rotación cambiaban el proxy y dejaban la cabecera antigua, de modo que las credenciales de un proxy iban a otro. Desde la 2.7.1, una cabecera puesta a mano se conserva solo «for as long as that proxy URL remains the same». Las credenciales de la URL siempre la sustituyen.

Dos resultados más del mismo código:

  • En la primera pasada la cabecera aún no está ligada a un proxy. Un middleware que cambió meta["proxy"] de A a B antes de que se ejecutara HttpProxyMiddleware envió la cabecera puesta a mano al proxy B.
  • Sin ningún proxy, la cabecera se eliminó antes de que la petición llegara al destino.

Variables de entorno: HTTP_PROXY se usa por defecto

Scrapy lee las variables de proxy cuando arranca el rastreo, y no hace falta ningún ajuste para activarlo. Con HTTP_PROXY=http://user:pass@127.0.0.1:8888 y sin meta["proxy"], la petición del laboratorio pasó por el proxy y se autenticó.

  • HTTP_PROXY y http_proxy en minúsculas se aplicaron a las URL http://, y HTTPS_PROXY a las URL https://. Con solo HTTP_PROXY definida, una petición https:// salió directa. ALL_PROXY se ignoró.
  • NO_PROXY=127.0.0.1 envió la petición directa. meta["proxy"] tuvo prioridad sobre las variables e ignoró NO_PROXY, como dice la documentación.
  • meta={"proxy": None} envió una petición directa con la variable definida.
  • HTTPPROXY_ENABLED = False hizo que Scrapy dejara de leer las variables. También desactivó el tratamiento de las credenciales: meta["proxy"] = "http://user:pass@127.0.0.1:8888" lanzó entonces ValueError: invalid hostname: user:pass@127.0.0.1 para una URL http:// y el TunnelError 407 para https://.

Para ignorar el entorno y que meta["proxy"] siga funcionando, sustituye el middleware por una subclase que descarta lo que leyó:

python
from scrapy.downloadermiddlewares.httpproxy import HttpProxyMiddleware


class ExplicitProxyMiddleware(HttpProxyMiddleware):
    def __init__(self, auth_encoding="latin-1"):
        super().__init__(auth_encoding)
        self.proxies = {}
python
DOWNLOADER_MIDDLEWARES = {
    "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": None,
    "myproject.middlewares.ExplicitProxyMiddleware": 750,
}

proxies no es un atributo documentado, así que vuelve a probarlo después de actualizar. aiohttp hace lo contrario: ignora las variables salvo que pases trust_env=True (Proxies en aiohttp 3.14). Variables de entorno de proxy compara otros clientes.

TunnelError: Could not open CONNECT tunnel with proxy

code
scrapy.core.downloader.handlers.http11.TunnelError: Could not open CONNECT tunnel with proxy 127.0.0.1:8888 [{'status': 407, 'reason': b'Proxy Authentication Required'}]

El proxy rechazó la petición CONNECT de una URL https://. El mensaje fue idéntico sin credenciales y con una contraseña incorrecta, y no contiene las credenciales, así que no te dice cuál de los dos casos tienes. Revisa la URL de meta["proxy"] en la petición que falla y después sigue el orden de comprobaciones de Corrige el error de proxy 407.

  • TunnelError está en RETRY_EXCEPTIONS. Con la configuración por defecto, el proxy del laboratorio recibió el CONNECT rechazado tres veces. El registro mostró Retrying <GET https://127.0.0.1:8443/> (failed 1 times), después (failed 2 times) y después Gave up retrying. Con "dont_retry": True en meta, el proxy recibió uno.
  • handle_httpstatus_list no cambia nada aquí. No hay respuesta, solo la excepción, así que trátala en un errback.

Ignoring response 407: HTTP status code is not handled or not allowed

code
Ignoring response <407 http://127.0.0.1:8080/>: HTTP status code is not handled or not allowed

Para una URL http://, el 407 del proxy es una respuesta normal y HttpErrorMiddleware la descarta. El callback no se llamó nunca, esta línea INFO fue la única señal y el rastreo se cerró con finish_reason: finished. Con un errback, la línea no se registra y el errback recibe scrapy.spidermiddlewares.httperror.HttpError: Ignoring non-200 response. Scrapy no reintentó la petición.

Para recibir la respuesta en el callback, permite ese estado en la petición:

python
yield scrapy.Request(url, meta={"proxy": PROXY, "handle_httpstatus_list": [407]})

El atributo del spider handle_httpstatus_list = [407] y el ajuste HTTPERROR_ALLOWED_CODES = [407] hicieron lo mismo (HttpErrorMiddleware). El callback recibió entonces el estado 407 con Proxy-Authenticate: Basic realm="lab". Sin ninguno de ellos, las estadísticas del rastreo cuentan las respuestas descartadas en httperror/response_ignored_status_count/407.

UnsupportedURLSchemeError: Unsupported scheme: b'socks5'

code
scrapy.exceptions.UnsupportedURLSchemeError: Unsupported scheme: b'socks5'

El download handler por defecto de Scrapy no admite SOCKS. Con meta["proxy"] = "socks5://127.0.0.1:1080", una petición http:// lanzó este error una vez, sin reintentos. socks5h:// lanzó Unsupported scheme: b'socks5h', y una URL SOCKS en HTTP_PROXY lanzó el mismo error.

Una petición https:// no lo lanzó. Scrapy envió CONNECT 127.0.0.1:8443 como HTTP en claro al puerto SOCKS, el servidor SOCKS del laboratorio cerró la conexión y Scrapy esperó el timeout de descarga tres veces antes de informar DownloadTimeoutError: Getting https://127.0.0.1:8443/ took longer than 15.0 seconds. El laboratorio fijó DOWNLOAD_TIMEOUT en 15. El valor por defecto es 180.

Hay dos salidas:

  • Usa un endpoint de proxy HTTP si tu proveedor tiene uno. Entonces se aplica todo lo anterior.
  • Cambia a HttpxDownloadHandler. Viene con Scrapy, y la documentación indica que admite SOCKS5 y lo marca como experimental y todavía no recomendado para producción.
sh
python -m pip install "scrapy[httpx]"
python
DOWNLOAD_HANDLERS = {
    "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler",
    "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler",
}

Con este handler, socks5:// devolvió 200 para ambos esquemas. Para http://localhost:8080/ el servidor SOCKS registró un nombre de dominio, así que el handler envía el nombre de host al proxy. socks5h:// también funcionó. socks5 vs socks5h muestra qué hacen otros clientes con los dos esquemas. Sus errores se leen distinto: un CONNECT rechazado apareció como scrapy.exceptions.DownloadConnectionRefusedError: 407 Proxy Authentication Required.

Reintentos: RetryMiddleware repite las peticiones POST

RetryMiddleware reintentó un POST igual que reintenta un GET. En el laboratorio, un POST pasó por el proxy hasta un destino que leyó la petición y cerró la conexión sin responder. Scrapy registró Retrying <POST http://127.0.0.1:8081/jobs> (failed 1 times) y lo envió de nuevo. El destino recibió el mismo cuerpo dos veces y el callback vio un solo 201.

Para una petición que no es seguro repetir, pon "dont_retry": True en meta (RetryMiddleware). El destino recibió entonces un solo POST y el errback recibió scrapy.exceptions.DownloadFailedError. Reintentos con proxy: una respuesta perdida, dos trabajos creados muestra qué cambia una clave de idempotencia.

Cada reintento es otra petición a través del proxy. ¿Cuántos GB de proxies necesitas? mide cuánto consume una descarga de solo HTML. Si quieres ayuda de diagnóstico dentro de un agente de programación, Proxy Toolkit MCP acepta descripciones de errores saneadas. Mantén las credenciales fuera de sus argumentos.

Cómo se probó

En Ubuntu 26.04.1 con Python 3.14.4, Scrapy 2.19.0, Twisted 26.4.0 y httpx2 2.13.1, todo en 127.0.0.1: python -m http.server como destinos HTTP y HTTPS, tres proxies que exigen autenticación Basic y registran cada petición con los nombres de sus cabeceras, un servidor SOCKS5 que registra cada destino y un destino que pierde la respuesta al primer POST. Cada uno de los 62 casos ejecutó scrapy runspider en un intérprete nuevo con un entorno limpio. No se usó ninguna cuenta de proxy. En Python 3.15.0, pip install scrapy falla sin un compilador de C porque brotli no tenía wheel cp315 el 14 de octubre de 2026; las soluciones probadas están en Python 3.15 para web scraping.

El archivo del laboratorio contiene el ejecutor, los casos, el proxy, el servidor SOCKS5, el README y los resultados registrados.

No se probó: macOS y Windows, autenticación de proxy Digest y NTLM, proxies SOCKS con autenticación, proxies a los que se llega por TLS, HTTP/2 y el download handler de Scrapy basado en aiohttp.

ipvolt es un servicio de proxies para desarrolladores que todavía no está abierto; únete a la lista de acceso anticipado y recibirás un solo correo cuando abra.

Fuentes y lecturas adicionales

Referencias técnicas usadas para esta guía. Consulta la documentación de tu versión instalada y la configuración compatible de tu proveedor.