Configurar un proxy en HTTPX: autenticación, entorno y tiempos de espera

13 min de lectura

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.

  1. Instala el cliente que vas a importar: python3 -m pip install httpx==0.28.1. La plantilla local también necesita openssl en 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.
  2. Construye httpx.Proxy con la URL HTTP del proxy y auth=(usuario, contraseña) cuando la clave tenga @, :, /, # o %.
  3. Abre httpx.Client(proxy=..., trust_env=False) y pide un origen HTTP que controles. Lee el log del proxy, no solo el estado.
  4. 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.
Cliente HTTPX con proxy= y trust_env=False

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.

  1. Deja la URL del proxy en http://127.0.0.1:PUERTO aunque el destino sea https://.
  2. Confía el origen con ssl.create_default_context(cafile="ca.pem") y pásalo como verify=. No pongas verify=False.
  3. Mira el log. Tiene que aparecer CONNECT host:puerto antes de celebrar el 200.
  4. 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.

HTTPS a través de un proxy HTTP con CA propia

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.

  1. Pon usuario y contraseña del proxy en httpx.Proxy(..., auth=(usuario, contraseña)) cuando la clave tenga caracteres reservados.
  2. Reserva auth= del cliente para el sitio.
  3. Trata HTTP 407 y ProxyError de CONNECT como resultados distintos. Anota la clase que atrapaste.
  4. No tomes "en curl funciona" como prueba de que HTTPX mandó la misma cabecera.
Proxy.auth frente a auth= del origen

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.

  1. Exporta HTTP_PROXY y HTTPS_PROXY a la URL del proxy con userinfo codificado. Pon NO_PROXY vacío cuando el destino deba usar el proxy.
  2. Construye Client(trust_env=True) sin proxy=. La petición del laboratorio volvió 200 y el proxy alfa-es vio el GET.
  3. Repite con NO_PROXY igual al hostname del origen. El origen siguió en 200. El proxy no vio ese GET. Eso es bypass, un caso propio.
  4. Repite con las mismas variables y trust_env=False. La petición fue directa. El proxy no vio nada.
HTTP_PROXY activo y trust_env=False

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.

AsyncClient con await y el mismo proxy

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.

Timeout de lectura después del primer byte
FalloLo que lanzó HTTPX 0.28.1Lo que no es
Proxy HTTP, Basic ausente o malaEstado 407Una excepción de CONNECT
CONNECT HTTPS, Basic ausente o malaProxyError ("407 Proxy Authentication Required")Una página 407 del origen
HTTPS local sin la CA del laboratorioConnectError / SSLCertVerificationError después de CONNECTPrueba de que CONNECT no ocurrió
Puerto de bucle cerradoConnectError / ConnectionRefusedErrorConnectTimeout
Bind sin listen en DarwinConnectTimeout (espera del fixture)Un rechazo, o latencia WAN
Cuerpo retrasado tras el primer byteReadTimeout al iterar iter_bytes()Un fallo de conexión
Origen /estado/503 con retries=2Estado 503, un golpeUn reintento de aplicación
Tabla de casos del laboratorio HTTPX del 14 de octubre de 2026

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.

Oxylabs: proxies residenciales y de datacenter
Oxylabs: proxies residenciales y de datacenter4.0770

Oxylabs vende proxies residenciales, de datacenter y móviles. El residencial de autoservicio empieza en $6/GB para 5 GB/mes. Pagos del comprador: tarjetas, transferencia, AliPay, PayPal.

10 países
0,59 US$
por 1 GB

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?

Pasa proxy= a httpx.Client o a httpx.get. Usa un string o httpx.Proxy. HTTPX 0.28.1 no tiene proxies=. HTTPX quitó ese argumento en 0.28.0. Para enrutar por esquema, monta HTTPTransport con su propio proxy y verify. mounts= sigue mezclando patrones del entorno si trust_env=True. Usa transport= o trust_env=False cuando HTTP_PROXY heredado no deba aplicar.

¿Por qué el proxy para HTTPS usa http://?

El salto del proxy suele ser HTTP plano. Los destinos HTTPS usan CONNECT por ese salto y luego TLS con el origen. La clave https:// en mounts es el esquema del destino. La URL del proxy se queda en http:// salvo que el producto hable TLS hacia el propio proxy.

¿En qué se diferencian las credenciales del proxy y auth=?

El userinfo de la URL del proxy o httpx.Proxy(auth=(usuario, contraseña)) mandan Proxy-Authorization al proxy. auth= autentica el origen. El laboratorio devolvió 407 o ProxyError cuando falló el proxy, y 401 del origen cuando solo faltaban las credenciales del sitio.

¿trust_env=False desactiva la verificación TLS?

No. trust_env=False impide que HTTPX lea HTTP_PROXY, NO_PROXY, SSL_CERT_FILE y SSL_CERT_DIR. La verificación TLS sigue. HTTPX no carga credenciales de origen desde netrc porque trust_env sea True. Si pasas httpx.NetRCAuth tú, esa auth sigue con trust_env=False. Pasa ssl.create_default_context(cafile=...) como verify= cuando necesites una CA privada. verify=False no es el interruptor del entorno.

¿Por qué aparece ReadTimeout después de las cabeceras?

El timeout de lectura es un plazo por trozo del cuerpo. El origen del laboratorio mandó cabeceras y un byte, y luego pausó. Al hacer stream, el estado fue 200, llegó ese byte y después ReadTimeout. No es ConnectTimeout ni un fallo al hablar con el proxy.

¿HTTPTransport reintenta respuestas 429 o 503?

No. retries en HTTPTransport reintenta ConnectError y ConnectTimeout. Con retries=2 el origen /estado/503 recibió un golpe y HTTPX devolvió 503. Si necesitas reintentos de estado, usa otra librería.