Configurar un proxy en HTTPX: autenticación, entorno y tiempos de espera
Configura proxy= en HTTPX 0.28.1, separa las credenciales del proxy y del sitio, deja HTTPS en CONNECT y distingue entorno, 407 y ReadTimeout.
Esta guía es para quien usa HTTPX en Python y necesita que el cliente salga por un proxy HTTP con usuario y contraseña, sin apagar TLS. Los ejemplos usan credenciales dummy. Top Proxy no vende IP ni reparte listas gratis. Un 200 de laboratorio solo demuestra esa ruta local.
01Configurar proxy= en HTTPX
HTTPX 0.28.1 recibe proxy= en httpx.get y en httpx.Client. La página de proxies del 14 de octubre de 2026 lo deja en una frase. Un string http://127.0.0.1:PUERTO o un httpx.Proxy mandan HTTP y HTTPS por el mismo salto. El argumento proxies= ya no existe. HTTPX 0.28.0 lo quitó el 28 de noviembre de 2024. Si un tutorial viejo te lo enseña, el runtime actual ni siquiera lo acepta.
Un 200 sin traza del proxy no dice por dónde salió la petición. En el recorrido de bucle local, un Client sin proxy= pegó al origen HTTP y el forwarder contó cero eventos. Eso es la base. No dice qué haría HTTPX con HTTP_PROXY todavía puesto.
- Instala el cliente que vas a importar:
python3 -m pip install httpx==0.28.1. La plantilla local también necesitaopensslen el PATH, para firmar una CA de un día. Este texto usó HTTPX 0.28.1, httpcore 1.0.9 y Python 3.14.6. - Construye
httpx.Proxycon la URL HTTP del proxy yauth=(usuario, contraseña)cuando la clave tenga@,:,/,#o%. - Abre
httpx.Client(proxy=..., trust_env=False)y pide un origen HTTP que controles. Lee el log del proxy, no solo el estado. - Deja el cliente en un
with. Al salir cierra el pool. Un cliente olvidado es un socket abierto, no un script más rápido.

Si necesitas dos salidas, no metas dos URLs en proxy=. La documentación enseña mounts con un HTTPTransport(proxy=...) por patrón. Cada transporte lleva su proxy, su verify y su trust_env. En 0.28.1, allow_env_proxies = trust_env and transport is None. Un transport= custom salta los proxies del entorno. Un dict mounts= no. Esa precedencia de mounts no se midió aquí. Si el entorno no debe intervenir, pon trust_env=False y listo.
SOCKS5 está documentado con pip install httpx[socks] y proxy='socks5://...'. Este recorrido no levantó un fixture SOCKS. No voy a inventarte un ATYP.
02Usar un proxy HTTP para un destino HTTPS
La gente llama a esto "proxy HTTPS" porque la web va con https://. El salto de delante sigue siendo un proxy HTTP que habla CONNECT. La documentación lo marca como trampa. La clave https:// en un dict de mounts es el esquema del destino. La URL del proxy suele ser http://.
Forward y túnel no son el mismo mecanismo. En HTTP el proxy pide al origen y te devuelve el cuerpo. En HTTPS el cliente manda CONNECT host:443, el proxy abre TCP y el TLS corre entre cliente y origen por ese tubo. Un 200 de CONNECT sin bytes TLS no es un proxy HTTPS. El laboratorio reenvió cuerpos reales en HTTP y bytes TLS reales después de CONNECT.
- Deja la URL del proxy en
http://127.0.0.1:PUERTOaunque el destino seahttps://. - Confía el origen con
ssl.create_default_context(cafile="ca.pem")y pásalo comoverify=. No pongasverify=False. - Mira el log. Tiene que aparecer
CONNECT host:puertoantes de celebrar el 200. - Repite una vez con el almacén CA por defecto. Quieres un fallo TLS que igual haya dejado CONNECT escrito.
Ese último paso es el que se salta. En esta corrida CONNECT funcionó y HTTPX lanzó ConnectError con SSLCertVerificationError: unable to get local issuer certificate. El túnel estaba bien. El certificado no. Una pestaña de Cuál es mi IP enseña la salida del navegador, no la de este cliente. El comprobador de proxies sirve para ver si un host responde HTTP o SOCKS5 antes de apuntar HTTPX.

El protocolo del proxy no es el origen de la IP. Un residencial y un datacenter pueden hablar el mismo HTTP. Si la tarea pide salida de operador, mira proxies móviles o residenciales. Este laboratorio no midió país, cuenta ni calidad de un proveedor.
03Separar las credenciales del proxy y del destino
auth= no es el proxy. auth= autentica el origen. Mezclarlos te deja una hora mirando el salto equivocado.
La documentación pone el usuario en la URL del proxy. La API también documenta httpx.Proxy(url, auth=(usuario, contraseña)). Las dos formas funcionaron contra el forwarder local. La contraseña dummy llevaba @, :, /, # y %. Esos caracteres van en el userinfo solo con codificación por componente, o en la tupla auth= de Proxy, que no pasa por el parser de URL. No imprimas Proxy-Authorization cuando depuras.
Falta de clave y clave mala no son un solo comportamiento.
En el reenvío HTTP, una petición sin usuario del proxy volvió HTTP 407. HTTPX lo dejó en status_code == 407. Una contraseña falsa hizo lo mismo. El forwarder anotó razon_auth: ausente e incorrecta. No hubo excepción. Si tu wrapper más arriba lanza, conserva el 407.
En CONNECT, las mismas credenciales dummy ausentes o malas lanzaron httpx.ProxyError con el texto 407 Proxy Authentication Required. No hubo objeto de respuesta que leer. CONNECT es el armado del túnel. Un 407 ahí es fallo de transporte, no una página del origen.
Con la tupla correcta, HTTP y HTTPS devolvieron 200. El origen nunca vio Proxy-Authorization. Esa cabecera se queda en el salto del proxy. Si el sitio también pide Basic, manda auth=(usuario_sitio, clave_sitio) en el cliente. La ruta /auth-origen del laboratorio contestó 401 sin ella y 200 con los dos saltos autenticados.
- Pon usuario y contraseña del proxy en
httpx.Proxy(..., auth=(usuario, contraseña))cuando la clave tenga caracteres reservados. - Reserva
auth=del cliente para el sitio. - Trata HTTP 407 y
ProxyErrorde CONNECT como resultados distintos. Anota la clase que atrapaste. - No tomes "en curl funciona" como prueba de que HTTPX mandó la misma cabecera.

Los productos residenciales documentan el mismo corte. El README de Oxylabs Residential del 14 de octubre de 2026 lista HTTP, HTTPS y SOCKS5, usuario/contraseña o lista blanca de IP, y el gateway pr.oxylabs.io. Eso es forma de endpoint en papel. No es un login a su pool.
04Comprobar variables de entorno y excepciones
HTTPX lee el entorno por defecto. Variables de entorno del mismo día cita HTTP_PROXY, HTTPS_PROXY, ALL_PROXY y NO_PROXY, más SSL_CERT_FILE y SSL_CERT_DIR. trust_env=False apaga ese conjunto. No significa "sáltate los certificados".
El proceso hijo del laboratorio arrancó sin esas variables, sin restos CGI, sin REQUESTS_CA_BUNDLE, sin CURL_CA_BUNDLE y sin SSLKEYLOGFILE. Usó un archivo NETRC vacío y privado. HTTPX no carga .netrc solo porque trust_env esté en True. Si pasas httpx.NetRCAuth a mano, eso sigue aplicando con trust_env=False. Esta guía no usa NetRCAuth.
127.0.0.1 a menudo se bypasea. Los casos de entorno usaron el nombre guia-httpx.local y NO_PROXY="", con un parche local de getaddrinfo que resuelve ese nombre a bucle. Ese parche es comportamiento inyectado. No es una medida de DNS público.
- Exporta
HTTP_PROXYyHTTPS_PROXYa la URL del proxy con userinfo codificado. PonNO_PROXYvacío cuando el destino deba usar el proxy. - Construye
Client(trust_env=True)sinproxy=. La petición del laboratorio volvió 200 y el proxyalfa-esvio el GET. - Repite con
NO_PROXYigual al hostname del origen. El origen siguió en 200. El proxy no vio ese GET. Eso es bypass, un caso propio. - Repite con las mismas variables y
trust_env=False. La petición fue directa. El proxy no vio nada.

trust_env=False también dejó de tomar SSL_CERT_FILE como almacén CA. Con la CA local en SSL_CERT_FILE y trust_env=True, HTTPS al origen del laboratorio volvió 200. Con trust_env=False y verify=True por defecto, la misma URL lanzó ConnectError / SSLCertVerificationError. Pasar verify=ssl.create_default_context(cafile=...) en ese cliente restauró el 200. La verificación siguió encendida. Solo se fue la lectura del entorno.
La precedencia de un proxy= explícito sobre un HTTP_PROXY distinto no se midió con un segundo forwarder. La documentación de mounts cubre el enrutado por patrón. Si lo necesitas, instrumenta dos identidades. Un 200 solo no te dice cuál hop se usó.
05Cerrar clientes síncronos y asíncronos
Síncrono y asíncrono son dos clientes. Comparten la forma de proxy=. No comparten el pool.
with httpx.Client(proxy=..., verify=ctx) as cliente: es la forma síncrona. El cierre ocurre al salir. async with httpx.AsyncClient(proxy=..., verify=ctx) as cliente: es la asíncrona. Haces await cliente.get(...). Un get sin await dentro del cliente async es un bug, no un timeout.
El caso async de este laboratorio usó AsyncClient con la misma URL de proxy HTTP y el contexto SSL local. await cliente.get(https_url) volvió 200 y el proxy escribió CONNECT. Es un túnel async que funcionó. No es un banco de concurrencia y no habla de reutilizar el pool bajo carga.
Los transportes montados piden el mismo cierre. Un HTTPTransport creado a mano lo cierra el cliente si el cliente lo posee por mounts o transport. Si te quedas con la variable, llama transport.close() en finally.
Los reintentos van en el transporte. Transports del 14 de octubre de 2026 dice que HTTPTransport(retries=...) reintenta ConnectError y ConnectTimeout. No reintenta 429 ni 503. Con retries=2 contra /estado/503, el origen contó un golpe. HTTPX devolvió 503. Eso no es el Retry de urllib3.

La plantilla de abajo arranca origen HTTP, origen HTTPS, proxy con Basic dummy y una CA de un día. Pide HTTPX 0.28.1 y openssl en el PATH. Es tuya cuando la corres. Sustituye host, puerto, usuario, contraseña y cafile cuando apuntes a un proxy real. Las cifras de 407, entorno y ReadTimeout de esta guía salieron de un recorrido de bucle aparte, con los mismos tipos de origen y proxy.
#!/usr/bin/env python3
"""Plantilla ejecutable: origen, proxy y CA que controla quien la corre.
Requisitos: python3 -m pip install httpx==0.28.1 y openssl en PATH.
Arranque: python3 plantilla.py
openssl firma la CA y el certificado del origen local. Sin openssl
material_tls falla al arrancar, cierra los listeners y termina.
Credenciales dummy. No llama a proveedores. TLS sigue verificado.
Sustituye host, puerto, usuario, contraseña y cafile por los tuyos
cuando apuntes a un proxy real.
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import select
import socket
import ssl
import subprocess
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import httpx
USUARIO = "usuario-lab"
CLAVE = "Cl4v3@:/#%"
QUITAR = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
]
def puerto() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def material_tls(d: Path) -> tuple[Path, Path, Path]:
ca_key, ca_crt = d / "ca.key", d / "ca.crt"
leaf_key, leaf_csr, leaf_crt = d / "hoja.key", d / "hoja.csr", d / "hoja.crt"
ca_cnf = d / "ca.cnf"
leaf_ext = d / "hoja.ext"
ca_cnf.write_text(
"[req]\ndistinguished_name=dn\nx509_extensions=v3_ca\n[dn]\n"
"[v3_ca]\nbasicConstraints=critical,CA:TRUE\n"
"keyUsage=critical,keyCertSign,cRLSign\nsubjectKeyIdentifier=hash\n",
encoding="utf-8",
)
leaf_ext.write_text(
"basicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\n"
"extendedKeyUsage=serverAuth\nsubjectKeyIdentifier=hash\n"
"authorityKeyIdentifier=keyid,issuer\n"
"subjectAltName=DNS:localhost,IP:127.0.0.1\n",
encoding="utf-8",
)
for cmd in (
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-sha256",
"-days",
"1",
"-nodes",
"-keyout",
str(ca_key),
"-out",
str(ca_crt),
"-subj",
"/CN=CA-plantilla-httpx",
"-config",
str(ca_cnf),
"-extensions",
"v3_ca",
],
[
"openssl",
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
str(leaf_key),
"-out",
str(leaf_csr),
"-subj",
"/CN=localhost",
],
[
"openssl",
"x509",
"-req",
"-in",
str(leaf_csr),
"-CA",
str(ca_crt),
"-CAkey",
str(ca_key),
"-CAcreateserial",
"-out",
str(leaf_crt),
"-days",
"1",
"-sha256",
"-extfile",
str(leaf_ext),
],
):
subprocess.check_call(cmd, cwd=d, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
os.chmod(ca_key, 0o600)
os.chmod(leaf_key, 0o600)
return ca_crt, leaf_crt, leaf_key
class Origen(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
return
def do_GET(self) -> None: # noqa: N802
cuerpo = json.dumps({"ok": True, "ruta": self.path}, ensure_ascii=False).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(cuerpo)))
self.end_headers()
self.wfile.write(cuerpo)
class ProxyHilo(threading.Thread):
def __init__(self, puerto_n: int) -> None:
super().__init__(daemon=True)
self.puerto_n = puerto_n
self.parar = threading.Event()
self.eventos: list[str] = []
self.sock: socket.socket | None = None
def run(self) -> None:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", self.puerto_n))
s.listen(16)
s.settimeout(0.3)
self.sock = s
while not self.parar.is_set():
try:
conn, _ = s.accept()
except socket.timeout:
continue
except OSError:
break
threading.Thread(target=self.atender, args=(conn,), daemon=True).start()
try:
s.close()
except OSError:
pass
self.sock = None
def atender(self, conn: socket.socket) -> None:
remoto = None
try:
buf = b""
conn.settimeout(4)
while b"\r\n\r\n" not in buf:
t = conn.recv(4096)
if not t:
break
buf += t
cabeza, _, resto = buf.partition(b"\r\n\r\n")
lineas = cabeza.decode("iso-8859-1", "replace").split("\r\n")
req = lineas[0]
headers = {}
for linea in lineas[1:]:
if ":" in linea:
k, v = linea.split(":", 1)
headers[k.strip().lower()] = v.strip()
metodo, destino, *_ = req.split(" ")
auth = headers.get("proxy-authorization", "")
esperado = "Basic " + base64.b64encode(f"{USUARIO}:{CLAVE}".encode()).decode()
if auth != esperado:
conn.sendall(b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\n\r\n")
self.eventos.append(f"{metodo} 407")
return
self.eventos.append(f"{metodo} {destino}")
if metodo == "CONNECT":
host, _, p = destino.partition(":")
remoto = socket.create_connection((host, int(p or "443")), 4)
conn.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
conn.setblocking(False)
remoto.setblocking(False)
pares = {conn: remoto, remoto: conn}
while True:
r, _, _ = select.select([conn, remoto], [], [], 0.3)
if not r:
continue
for src in r:
data = src.recv(16384)
if not data:
return
pares[src].sendall(data)
else:
from urllib.parse import urlsplit
u = urlsplit(destino)
remoto = socket.create_connection((u.hostname, u.port or 80), 4)
path = u.path or "/"
bloque = f"{metodo} {path} HTTP/1.1\r\nHost: {u.netloc}\r\nConnection: close\r\n\r\n"
remoto.sendall(bloque.encode() + resto)
while True:
data = remoto.recv(16384)
if not data:
break
conn.sendall(data)
finally:
for x in (conn, remoto):
if x is None:
continue
try:
x.close()
except OSError:
pass
def cerrar_origen(srv: ThreadingHTTPServer | None, arrancado: bool) -> None:
if srv is None:
return
if arrancado:
try:
srv.shutdown()
except Exception:
pass
try:
srv.server_close()
except Exception:
pass
def main() -> int:
for k in QUITAR:
os.environ.pop(k, None)
tmp = Path(tempfile.mkdtemp(prefix="httpx-plantilla-"))
http_p, https_p, proxy_p = puerto(), puerto(), puerto()
origen_http = ThreadingHTTPServer(("127.0.0.1", http_p), Origen)
origen_https = ThreadingHTTPServer(("127.0.0.1", https_p), Origen)
proxy = ProxyHilo(proxy_p)
http_arrancado = False
https_arrancado = False
proxy_arrancado = False
try:
ca_crt, leaf_crt, leaf_key = material_tls(tmp)
ctx_srv = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx_srv.load_cert_chain(str(leaf_crt), str(leaf_key))
origen_https.socket = ctx_srv.wrap_socket(origen_https.socket, server_side=True)
threading.Thread(target=origen_http.serve_forever, daemon=True).start()
http_arrancado = True
threading.Thread(target=origen_https.serve_forever, daemon=True).start()
https_arrancado = True
proxy.start()
proxy_arrancado = True
time.sleep(0.15)
ctx = ssl.create_default_context(cafile=str(ca_crt))
proxy_obj = httpx.Proxy(f"http://127.0.0.1:{proxy_p}", auth=(USUARIO, CLAVE))
timeout = httpx.Timeout(5.0, connect=2.0, read=2.0)
with httpx.Client(proxy=proxy_obj, verify=ctx, trust_env=False, timeout=timeout) as c:
r_http = c.get(f"http://127.0.0.1:{http_p}/")
r_https = c.get(f"https://127.0.0.1:{https_p}/")
async def uno() -> int:
async with httpx.AsyncClient(proxy=proxy_obj, verify=ctx, trust_env=False, timeout=timeout) as c:
r = await c.get(f"https://127.0.0.1:{https_p}/")
return r.status_code
async_estado = asyncio.run(uno())
print(json.dumps({
"http": r_http.status_code,
"https": r_https.status_code,
"async_https": async_estado,
"proxy_eventos": proxy.eventos,
}, ensure_ascii=False, indent=2))
return 0 if r_http.status_code == r_https.status_code == async_estado == 200 else 1
finally:
if proxy_arrancado:
proxy.parar.set()
if proxy.sock is not None:
try:
proxy.sock.close()
except OSError:
pass
cerrar_origen(origen_http, http_arrancado)
cerrar_origen(origen_https, https_arrancado)
import shutil
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())
06Distinguir fallos de conexión y lectura
Nombra la excepción que atrapaste. HTTPX tiene jerarquía de verdad. Fundir todo en "error de proxy" esconde el salto que falló.
Un listener de bucle ya cerrado produjo ConnectError envolviendo ConnectionRefusedError: [Errno 61] Connection refused. Eso es un rechazo. No es un timeout de conexión. En este Darwin, un socket enlazado y retenido sin listen no rechazó. HTTPX lanzó ConnectTimeout envolviendo TimeoutError al vencer el connect de 0,5 s. Esa espera es del fixture bind-sin-listen. No es un RTT de internet.
Los timeouts de lectura van de los trozos después de que la respuesta empezó. Timeouts separa connect, read, write y pool. El origen /cuerpo-lento mandó cabeceras completas, escribió y flusheó un byte, y luego esperó más que Timeout(read=0.45). Al iterar el cuerpo, HTTPX había visto estado 200, el evento de primer byte del origen, un byte en el cliente, y entonces ReadTimeout envolviendo TimeoutError. Las cabeceras ya habían llegado. ConnectError habría sido la clase equivocada.
Los fallos TLS después de CONNECT se quedaron en ConnectError con SSLCertVerificationError. El proxy ya había aceptado el túnel. verify=False lo habría tapado.

| Fallo | Lo que lanzó HTTPX 0.28.1 | Lo que no es |
|---|---|---|
| Proxy HTTP, Basic ausente o mala | Estado 407 | Una excepción de CONNECT |
| CONNECT HTTPS, Basic ausente o mala | ProxyError ("407 Proxy Authentication Required") | Una página 407 del origen |
| HTTPS local sin la CA del laboratorio | ConnectError / SSLCertVerificationError después de CONNECT | Prueba de que CONNECT no ocurrió |
| Puerto de bucle cerrado | ConnectError / ConnectionRefusedError | ConnectTimeout |
| Bind sin listen en Darwin | ConnectTimeout (espera del fixture) | Un rechazo, o latencia WAN |
| Cuerpo retrasado tras el primer byte | ReadTimeout al iterar iter_bytes() | Un fallo de conexión |
Origen /estado/503 con retries=2 | Estado 503, un golpe | Un reintento de aplicación |

La figura es la tabla de esta corrida. No es una portada ni una captura del comprobador.
Cuando el camino del cliente es honesto, elige un host de entrada que documente reenvío HTTP y CONNECT. La ficha de Oxylabs encaja en papel con usuario/contraseña o IP en lista blanca. Este texto no inició sesión en ese producto y no lo ordena.
Si solo querías confirmar el navegador, quédate en Cuál es mi IP. Si querías HTTPX, cree al log del proxy y a la clase de excepción de esta biblioteca. Discrepan más a menudo de lo que admiten los posts.
¿Cómo configuro un proxy en HTTPX?
¿Por qué el proxy para HTTPS usa http://?
¿En qué se diferencian las credenciales del proxy y auth=?
¿trust_env=False desactiva la verificación TLS?
¿Por qué aparece ReadTimeout después de las cabeceras?
¿HTTPTransport reintenta respuestas 429 o 503?
Artículos relacionados
Proxy con curl: configuración, autenticación y errores
Configura curl con un proxy HTTP o SOCKS5, separa -U de -u, lee CONNECT, http_proxy y los códigos 407, 7, 28 y 60.
Gemini Developer API: configurar un proxy HTTP y diagnosticar la conexión
Enruta google-genai 2.30.1 por HTTPS_PROXY y CONNECT, conserva TLS con SSL_CERT_FILE y separa un fallo de transporte de un 403 o un timeout.
Cómo configurar un proxy en Playwright
Configura un proxy HTTP o SOCKS5 en Playwright Python, en el browser o en el context, comprueba CONNECT y 407, y deja TLS encendido.




