Integración8 min de lectura

Proxies en curl_cffi: autenticación, error 407 y SOCKS

Configura proxies en curl_cffi 0.16.3: credenciales e impersonate, por qué un 407 en CONNECT lanza ConnectionError y no ProxyError, HTTP_PROXY y socks5h.

En esta página

Pon las credenciales en la URL del proxy y pásala como proxy:

python
from curl_cffi import requests

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

with requests.Session(impersonate="chrome", proxy=PROXY) as session:
    response = session.get("https://example.com/")
    print(response.status_code)

Para detectar los fallos del proxy, captura ProxyError y ConnectionError, y comprueba si status_code es 407. En curl_cffi 0.16, un proxy que rechaza CONNECT lanza ConnectionError, no ProxyError.

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 10 de octubre de 2026 con curl_cffi 0.16.3 (libcurl 8.21.0) en Python 3.14.4. impersonate elige el perfil de navegador del que curl_cffi copia los ajustes de TLS y HTTP/2. Todos los resultados fueron iguales con y sin él.

Tres detalles antes de los errores:

  • Usa proxy o proxies, no los dos. proxies={"http": PROXY, "https": PROXY} funcionó igual en una petición y en una Session. La clave es el esquema de la URL que pides: con solo la clave "https", una petición http:// salió directa. Pasar los dos argumentos lanzó TypeError: Cannot specify both 'proxy' and 'proxies'.
  • Codifica la contraseña de la URL con porcentajes. p@ss:w/rd funcionó como p%40ss%3Aw%2Frd. Sin codificar, la petición lanzó ProxyError con curl: (5) Unsupported proxy syntax. proxy_auth=("user", "p@ss:w/rd") junto a una URL de proxy sin credenciales también funcionó y no necesita codificación.
  • Escribe http:// para un proxy HTTP, también con URL https://. El esquema describe la conexión con el proxy. Una URL de proxy https:// apuntada al proxy HTTP sin TLS del laboratorio lanzó SSLError con curl: (35) TLS connect error y WRONG_VERSION_NUMBER. SSL WRONG_VERSION_NUMBER con proxy explica ese mensaje.

CONNECT tunnel failed, response 407: ConnectionError, no ProxyError

code
curl_cffi.requests.exceptions.ConnectionError: Failed to perform, curl: (7) CONNECT tunnel failed, response 407. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.

Para una URL https://, curl_cffi envía primero CONNECT al proxy. El proxy del laboratorio respondió 407 y la petición lanzó este error. El mensaje fue idéntico sin credenciales y con una contraseña incorrecta.

  • isinstance(error, ProxyError) es False. ProxyError y ConnectionError son clases hermanas bajo RequestException, así que except ProxyError no lo captura.
  • error.code es 7.
  • error.response es una Response cuyo status_code es 0, no 407. El estado solo está en el mensaje.
  • El código 7 no es exclusivo de un 407. Cuando el proxy del laboratorio no pudo llegar al destino respondió 502, y el mensaje fue curl: (7) CONNECT tunnel failed, response 502. Sin nada escuchando en el puerto del proxy fue curl: (7) Failed to connect to 127.0.0.1:8443 over proxy 127.0.0.1 after 0 ms: Could not connect to server.

La clase depende del código de error de curl, y ese código cambió entre versiones. Ejecutamos la misma petición en doce de ellas:

curl_cffilibcurlCONNECT rechazadoFallo de SOCKS
0.16.0 a 0.16.38.21.0ConnectionError, código curl 7ProxyError, código curl 97
0.7.4 a 0.15.08.7.0 a 8.15.0ProxyError, código curl 56ProxyError, código curl 97

Hasta la 0.15.0 el mensaje era curl: (56) CONNECT tunnel failed, response 407. La línea de comandos de curl pasó del código de salida 56 al 7 en la 8.18.0. Corrige el error de proxy 407 tiene esa tabla y el orden de comprobaciones para las credenciales.

Este bloque gestiona un CONNECT rechazado antes y después del cambio, un fallo de SOCKS y una respuesta 407:

python
import re

from curl_cffi.requests.exceptions import ConnectionError, ProxyError

CONNECT_STATUS = re.compile(r"CONNECT tunnel failed, response (\d{3})")


class ProxyFailure(Exception):
    pass


def fetch(session, url):
    try:
        response = session.get(url)
    except ProxyError as error:
        raise ProxyFailure(f"proxy error, curl {error.code}") from error
    except ConnectionError as error:
        rejected = CONNECT_STATUS.search(str(error))
        if error.code == 7 and rejected:
            raise ProxyFailure(f"proxy answered CONNECT with {rejected[1]}") from error
        raise
    if response.status_code == 407:
        raise ProxyFailure("proxy answered 407")
    return response

En el laboratorio informó proxy answered CONNECT with 407, proxy answered CONNECT with 502, proxy answered 407 para una URL http:// y proxy error, curl 97 para un fallo de SOCKS. Con el puerto del proxy cerrado volvió a lanzar el ConnectionError.

407 en URL http://: una respuesta, no una excepción

Para una URL http:// no hay túnel. curl_cffi envía la URL completa al proxy en un GET normal, como hace desde la 0.7.0b7 (issue #326), y el 407 del proxy vuelve como respuesta.

No se lanzó ninguna excepción. response.status_code fue 407, response.ok fue False y las cabeceras traían Proxy-Authenticate: Basic realm="lab". El código que solo captura excepciones trata esto como una petición correcta con el cuerpo vacío.

response.raise_for_status() lanzó curl_cffi.requests.exceptions.HTTPError: HTTP Error 407: Proxy Authentication Required. En esa excepción, error.response.status_code es 407.

Variables de entorno: http_proxy se lee, HTTP_PROXY no

Sin proxy en el código, libcurl toma uno del entorno:

  • Para una URL http://, HTTP_PROXY en mayúsculas se ignoró y la petición salió directa. Con http_proxy en minúsculas pasó por el proxy.
  • Para una URL https:// se usaron tanto HTTPS_PROXY como https_proxy. Con solo http_proxy, una petición https:// salió directa.
  • ALL_PROXY y all_proxy se aplicaron a los dos esquemas.
  • NO_PROXY=127.0.0.1 y no_proxy en minúsculas enviaron la petición directa.

libcurl documenta la excepción: las variables también se comprueban en mayúsculas, "except the http_proxy one which is only used lowercase" (variables de entorno de libcurl). El motivo es CGI: un servidor convierte una cabecera de petición Proxy: en una variable HTTP_PROXY para el script (Everything curl).

Scrapy lee HTTP_PROXY en mayúsculas (Proxies en Scrapy 2.19), y aiohttp no lee ninguna variable salvo que pases trust_env=True (Proxies en aiohttp 3.14). Variables de entorno de proxy compara más clientes.

Dos resultados son fáciles de pasar por alto:

  • NO_PROXY anula el argumento proxy. Con NO_PROXY=127.0.0.1 definida, requests.get(url, proxy=PROXY) salió directa con los dos esquemas. Al añadir curl_options={CurlOpt.NOPROXY: ""} volvió a pasar por el proxy. Frente a http_proxy ganó el argumento.
  • trust_env=False no desactivó las variables. Una Session(trust_env=False) siguió usando http_proxy y HTTPS_PROXY. proxy="" tampoco las desactivó.

Dos ajustes sí enviaron la petición directa con las variables definidas:

python
from curl_cffi import CurlOpt, requests

requests.get("https://example.com/", proxies={"all": ""})
requests.get("https://example.com/", curl_options={CurlOpt.NOPROXY: "*"})

Los dos funcionan también como argumentos de Session. Una cadena de proxy vacía "explicitly disables the use of a proxy, even if there is an environment variable set for it" (CURLOPT_PROXY).

SOCKS: socks5:// resuelve en local, socks5h:// envía el nombre de host

El laboratorio pidió http://dnsprobe.example:8080/, un nombre que solo existe en el archivo hosts como 127.0.0.2, a través de un servidor SOCKS5 que registra cada destino:

  • Con socks5://127.0.0.1:1080 el servidor registró CONNECT atyp=IPv4 dest=127.0.0.2:8080. curl_cffi resolvió el nombre por su cuenta.
  • Con socks5h://127.0.0.1:1080 registró CONNECT atyp=DOMAIN dest=dnsprobe.example:8080. El proxy recibió el nombre de host.

Una URL https:// dio los mismos dos resultados. Es la convención del propio curl (Usar un proxy con curl). socks5 vs socks5h enumera los clientes que no la siguen.

Un handshake SOCKS fallido es el caso que sí lanza ProxyError:

code
curl_cffi.requests.exceptions.ProxyError: Failed to perform, curl: (97) cannot complete SOCKS5 connection to 127.0.0.1. (1). See https://curl.se/libcurl/c/libcurl-errors.html first for more details.

error.code es 97. El último número entre paréntesis es el código de respuesta que envió el servidor SOCKS: el del laboratorio respondió 1, fallo general. Cuando el puerto de destino estaba cerrado y el servidor respondió 5, el mensaje terminó en (5).

Un esquema que no corresponde al puerto falla de otras maneras:

  • socks5:// apuntado al puerto del proxy HTTP lanzó ProxyError con curl: (97) Received invalid version in initial SOCKS5 response.
  • http:// apuntado al puerto SOCKS lanzó ProxyError con curl: (56) Proxy CONNECT aborted para una URL https://.
  • Sin nada escuchando en el puerto SOCKS, el error fue otra vez el ConnectionError con código 7.

AsyncSession: la misma configuración, los mismos errores

python
import asyncio

from curl_cffi import AsyncSession

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


async def main():
    async with AsyncSession(impersonate="chrome", proxy=PROXY) as session:
        response = await session.get("https://example.com/")
        print(response.status_code)


asyncio.run(main())

Cada caso asíncrono coincidió con su equivalente síncrono. Un CONNECT rechazado lanzó el mismo ConnectionError con código 7. Una URL http:// devolvió el 407 como respuesta. Un fallo de SOCKS lanzó ProxyError con código 97. socks5:// y socks5h:// enviaron los mismos tipos de dirección, y HTTP_PROXY se ignoró mientras que http_proxy se usó.

Reintentos: retry repite las peticiones POST

curl_cffi no reintenta por defecto. El laboratorio envió un POST a través del proxy a un destino que lee la petición y cierra la conexión sin responder. La llamada lanzó ConnectionError con curl: (52) Empty reply from server, y el destino había recibido un POST.

Con requests.Session(proxy=PROXY, retry=1) la misma llamada devolvió 201, y el destino había recibido el cuerpo dos veces. retry repitió el POST igual que repite un GET.

retry=2 ante un CONNECT rechazado envió tres peticiones CONNECT y lanzó el mismo error, con Session y con AsyncSession. Un reintento no arregla unas credenciales incorrectas.

Deja retry en 0 para las peticiones que no es seguro repetir, o envía una clave de idempotencia. Reintentos con proxy: una respuesta perdida, dos trabajos creados muestra qué cambia una clave. 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 y curl_cffi 0.16.3 (libcurl 8.21.0-IMPERSONATE con BoringSSL), todo en direcciones de loopback: python -m http.server como destinos HTTP y HTTPS, tres proxies que exigen autenticación Basic y registran cada petición, un servidor SOCKS5 que registra cada destino y otro que responde a cada petición con el código 1, y un destino que pierde la respuesta al primer POST. dnsprobe.example solo existía en una copia privada del archivo hosts. Cada uno de los 86 casos se ejecutó en un intérprete nuevo con un entorno limpio. La comparación de versiones instaló doce versiones, de la 0.7.4 a la 0.16.3. No se usó ninguna cuenta de proxy.

El archivo del laboratorio contiene el ejecutor, los casos, el proxy, el servidor SOCKS5, el README, los resultados registrados y la comparación de versiones.

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/3 y WebSockets.

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.