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.
Esta guía es para quien lanza Playwright en Python y quiere que Chromium salga por un proxy HTTP o SOCKS5, y que sepa leer un 407 distinto de un certificado rechazado. Los ejemplos usan credenciales dummy. Top Proxy no vende IP ni reparte listas gratis. Un 200 de laboratorio no demuestra geografía, cuenta ni calidad de un proveedor.
01Configurar el proxy en el browser o en el context
La documentación de red de Playwright para Python del 14 de octubre de 2026 dice que el proxy puede ir en todo el browser o en un context. La misma forma aparece en browser_type.launch y en browser.new_context. Los campos son server, opcional bypass, opcional username y opcional password. El laboratorio ejecutó Playwright para Python 1.64.0 contra Chromium 156.0.8078.4 en Python 3.14.6. Node no fue un segundo laboratorio.
Pon el proxy en chromium.launch si todos los context del mismo proceso deben salir por el mismo host. Ponlo en browser.new_context si un proceso necesita dos salidas o dos tarros de cookies.
- Decide si el recorrido pide un solo exit o dos identidades.
- Para un exit, pasa
proxyalaunch. - Para dos exits, arranca el browser sin segunda identidad y luego un
proxydistinto por context. - Cierra cada context que hayas creado antes de
browser.close(). La API del browser lo marca como limpieza, no como adorno.
Sin proxy, Chromium pegó al origen HTTP local con estado 200. El proxy de bucle abierto contó cero eventos. Antes de arrancar, el laboratorio borró HTTP_PROXY, HTTPS_PROXY, ALL_PROXY y NO_PROXY. Eso es una base limpia sin opción de proxy. No prueba cómo Playwright trataría una variable HTTP_PROXY heredada.
Un segundo browser arrancó con el proxy abierto como valor de launch. Un context recibió proxy.server hacia otro forwarder local. La petición volvió con 200. Los golpes cayeron en el segundo forwarder. El primero no vio la URL de origen de ese context. El proxy del context sustituye el valor del browser para ese context. No se mezclan.
En 1.64.0 no hay rotateSession en launch ni en new_context. Quien quiera girar IP lo hace en el producto del proveedor, no en esta API.
02Pasar host, puerto y credenciales sin exponerlas
server es una URL. La documentación de launch cita http://myproxy.com:3128 y socks5://myproxy.com:3128. La forma corta myproxy.com:3128 se toma como HTTP. Si el proveedor habla SOCKS5, el esquema tiene que ser socks5://. username y password están documentados para un proxy HTTP con login, no como userpass de SOCKS5.
La contraseña vive en el entorno o en el almacén de secretos, no en un archivo committed. Playwright igual manda Proxy-Authorization por el cable. El laboratorio usó el usuario dummy lab-user y una contraseña dummy con @, :, /, # y %. En los logs de resultado aparece ***.
Contra el proxy Basic local, Chromium se comportó así:
- Solo
server, sin usuario.page.gotoal origen HTTP devolvió estado 407. El proxy escribióauthReason: missing. Playwright no lanzó excepción. El estado del objeto de respuesta fue la prueba. - Usuario
lab-usery una contraseña falsa. Otra vez estado 407. El log mostróauthReason: wrong. - La contraseña dummy correcta. Estado 200. El log mostró
authorized: true.
Credenciales falsas, en este Chromium, no son un socket colgado. Son HTTP 407 del proxy, visibles como estado de navegación. Si un wrapper más arriba lanza, conserva el 407 y la clase de la excepción. No los fundas en "el proxy no va".
La autenticación HTTP de la misma página de red es http_credentials para el origen, no proxy.username. Un 401 del origen y un 407 del proxy son saltos distintos. Quien los mezcla depura la capa equivocada.
03Comprobar HTTPS a través de un proxy HTTP (CONNECT)
El esquema de la URL destino y el esquema de la URL del proxy son dos mandos. Un origen https://example.com/ igual sale por un proxy HTTP en http://127.0.0.1:<puerto>. El método del proxy es CONNECT, destino example.com:443. El laboratorio vio exactamente ese CONNECT. Después Chromium comprobó TLS contra el certificado público y page.goto devolvió 200. Eso es HTTPS a través de un proxy HTTP con verificación encendida.
Un origen HTTPS local con CA de laboratorio es la otra mitad. Chromium 156.0.8078.4, ignore_https_errors en el valor documentado False, navegó a https://127.0.0.1:<puerto-https>/ por el mismo proxy HTTP. El proxy escribió CONNECT 127.0.0.1:<puerto-https>. Después Playwright lanzó Error con net::ERR_CERT_AUTHORITY_INVALID. CONNECT ocurrió. TLS igual falló. Que el destino lleve https:// no convierte el proxy en un "proxy https". Que el proxy acepte CONNECT no hace que Chromium confíe en una CA privada.
Python ssl contra el mismo origen sin CA terminó en SSLCertVerificationError: unable to get local issuer certificate. Con la CA de laboratorio y verify_mode=CERT_REQUIRED llegó HTTP 200. Ese es el camino de confianza fuera del browser. client_certificates en new_context, añadido en v1.46, es un certificado de cliente para un origen nombrado, no un archivo de CA. El interruptor documentado de Playwright que saltaría el error es ignore_https_errors=True. Este texto no lo activa. verify=False no fue un caso.
Si la tarea es "demostrar CONNECT", lee el log del proxy en CONNECT host:puerto. Si la tarea es "página cargada", necesitas un certificado que Chromium ya acepte, o una CA fuera de esta guía. Una página de comprobación en el browser privado es la salida del browser, no el camino de Playwright. Cuál es mi IP sirve para la salida del browser. El comprobador de proxies dice si un host acepta HTTP o SOCKS5 antes de apuntar Playwright. Ninguno sustituye un trace de Playwright.

La figura es la tabla de este recorrido, no una portada 16:9 ni una captura del comprobador.
El script público de abajo arranca origen, proxy CONNECT y Chromium en local, escribe JSON en stdout y termina con código 0 o 1. No pide sidecars privados. Las claves temporales las borra en finally.
04Usar Playwright para Python en el recorrido ejecutable
Los ejemplos de red en Python usan chromium.launch(proxy={...}) y browser.new_context(proxy={...}). Sync y async llevan las mismas claves. Este laboratorio ejecuta el binding sync.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
proxy={
"server": "http://127.0.0.1:3128",
"username": "lab-user",
"password": "***",
}
)
page = browser.new_page()
page.set_default_timeout(8000)
page.goto("http://127.0.0.1:8080/")
browser.close()
new_page() sin context propio hereda el proxy del browser. Un context propio con otro server lo sustituye, como mostró el segundo forwarder. Pon timeout. Sin timeout, goto puede quedarse en un proxy mudo. En el laboratorio, un listener que aceptó TCP y nunca respondió entregó TimeoutError a los 8000 ms, medidos 8063 ms. Eso está acotado, no es eterno.
bypass es una lista separada por comas. Los ejemplos de la API citan hosts como .com o chromium.org. No es un interruptor universal.
El script siguiente es el mismo, byte a byte, que tests/public_lab.py. Corre solo.
#!/usr/bin/env python3
"""Laboratorio independiente de bucle local para Playwright Python.
Credenciales dummy. Origen local y proxy CONNECT. Sin proveedor de pago.
TLS permanece verificado. Este script no pone ignore_https_errors en True.
python3 -m pip install playwright==1.64.0
python3 -m playwright install chromium
python3 public_lab.py
"""
from __future__ import annotations
import base64
import json
import os
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
USER = "lab-user"
PASSWORD = "p@ss:" + "/#%"
REDACT = "***"
TIMEOUT_MS = 8000
JOIN_S = 2.0
STRIP = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
]
def redact(s: object) -> str:
text = str(s)
blob = base64.b64encode(f"{USER}:{PASSWORD}".encode("utf-8")).decode("ascii")
return text.replace(PASSWORD, REDACT).replace(blob, REDACT)
def isolate_env() -> None:
for key in STRIP:
os.environ.pop(key, None)
def basic() -> str:
return "Basic " + base64.b64encode(f"{USER}:{PASSWORD}".encode("utf-8")).decode("ascii")
def close_sock(sock: socket.socket | None) -> None:
if sock is None:
return
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
sock.close()
except OSError:
pass
def leftover_listen(ports: list[int]) -> list[int]:
still = []
for port in ports:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.2)
try:
s.connect(("127.0.0.1", int(port)))
still.append(int(port))
except OSError:
pass
finally:
s.close()
return still
class Origin(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None: # noqa: A003
return
def do_GET(self) -> None: # noqa: N802
body = json.dumps({"ok": True, "path": self.path, "via": "http-origin"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class TrackingHTTPServer(ThreadingHTTPServer):
def __init__(self, addr: tuple[str, int], handler: type[Origin], track_name: str = "public-http"):
super().__init__(addr, handler)
self.track_name = track_name
self.accepted: list[socket.socket] = []
self.handlers: list[threading.Thread] = []
self.daemon_threads = True
def get_request(self) -> tuple[socket.socket, object]:
req, addr = super().get_request()
self.accepted.append(req)
return req, addr
def process_request(self, request: socket.socket, client_address: object) -> None:
t = threading.Thread(
target=self.process_request_thread,
args=(request, client_address),
daemon=True,
name=f"{self.track_name}-handler",
)
self.handlers.append(t)
t.start()
def _serve_loop(server: ThreadingHTTPServer, stop: threading.Event) -> None:
try:
server.socket.settimeout(0.2)
while not stop.is_set():
try:
server.handle_request()
except OSError:
if stop.is_set():
break
except Exception:
return
class OwnedHttp:
def __init__(self, server: TrackingHTTPServer, thread: threading.Thread | None, started: bool):
self.server = server
self.thread = thread
self.started = started
self.port = int(server.server_address[1])
self.stop = threading.Event()
def close(self) -> list[str]:
errors: list[str] = []
self.stop.set()
for s in list(self.server.accepted):
close_sock(s)
try:
self.server.server_close()
except Exception as e: # noqa: BLE001
errors.append(str(e))
close_sock(getattr(self.server, "socket", None))
for t in list(self.server.handlers):
t.join(JOIN_S)
if t.is_alive():
errors.append(f"handler-alive:{t.name}")
if self.started and self.thread is not None:
self.thread.join(JOIN_S)
if self.thread.is_alive():
errors.append(f"thread-alive:{self.thread.name}")
return errors
def live_threads(self) -> list[threading.Thread]:
out: list[threading.Thread] = []
if self.thread is not None:
out.append(self.thread)
out.extend(self.server.handlers)
return out
def _call_thread_start(thread: threading.Thread, fault: bool) -> None:
if not fault:
thread.start()
return
real = threading.Thread.start
def boom(th_self: threading.Thread, *a: object, **k: object) -> None:
threading.Thread.start = real
raise RuntimeError("inject-thread-start")
threading.Thread.start = boom # type: ignore[method-assign]
try:
thread.start()
finally:
threading.Thread.start = real
def start_http_origin(fault_start: bool = False) -> OwnedHttp:
server = TrackingHTTPServer(("127.0.0.1", 0), Origin, track_name="public-http")
owned = OwnedHttp(server, None, False)
thread = threading.Thread(target=lambda: _serve_loop(server, owned.stop), daemon=True, name="public-http")
owned.thread = thread
try:
_call_thread_start(thread, fault_start)
owned.started = True
return owned
except Exception:
owned.close()
raise
def make_certs(tmp: str) -> dict[str, str]:
ca_key = os.path.join(tmp, "ca.key")
ca_pem = os.path.join(tmp, "ca.pem")
origin_key = os.path.join(tmp, "origin.key")
origin_csr = os.path.join(tmp, "origin.csr")
origin_pem = os.path.join(tmp, "origin.pem")
ca_cnf = os.path.join(tmp, "ca.cnf")
ext = os.path.join(tmp, "ext.cnf")
with open(ca_cnf, "w", encoding="utf-8") as fh:
fh.write(
"[req]\n"
"distinguished_name=dn\n"
"x509_extensions=v3_ca\n"
"prompt=no\n"
"[dn]\n"
"CN=pw-es-public-lab-ca\n"
"[v3_ca]\n"
"basicConstraints=critical,CA:TRUE\n"
"keyUsage=critical,keyCertSign,cRLSign\n"
"subjectKeyIdentifier=hash\n"
)
with open(ext, "w", encoding="utf-8") as fh:
fh.write(
"subjectAltName=IP:127.0.0.1,DNS:localhost\n"
"basicConstraints=CA:FALSE\n"
"keyUsage=digitalSignature,keyEncipherment\n"
"extendedKeyUsage=serverAuth\n"
"subjectKeyIdentifier=hash\n"
"authorityKeyIdentifier=keyid,issuer\n"
)
def run(args: list[str]) -> None:
r = subprocess.run(["openssl", *args], capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(r.stderr or r.stdout)
run(["req", "-x509", "-newkey", "rsa:2048", "-keyout", ca_key, "-out", ca_pem, "-days", "1", "-nodes", "-config", ca_cnf])
run(["req", "-newkey", "rsa:2048", "-keyout", origin_key, "-out", origin_csr, "-nodes", "-subj", "/CN=localhost"])
run(["x509", "-req", "-in", origin_csr, "-CA", ca_pem, "-CAkey", ca_key, "-CAcreateserial", "-out", origin_pem, "-days", "1", "-extfile", ext])
return {
"caPem": open(ca_pem, encoding="utf-8").read(),
"originKey": origin_key,
"originPem": origin_pem,
}
def start_https_origin(certs: dict[str, str], fault_tls: bool = False, fault_start: bool = False) -> OwnedHttp:
server = TrackingHTTPServer(("127.0.0.1", 0), Origin, track_name="public-https")
owned = OwnedHttp(server, None, False)
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
if fault_tls:
ctx.load_cert_chain("/nonexistent-pw-es-public.pem", "/nonexistent-pw-es-public.key")
else:
ctx.load_cert_chain(certs["originPem"], certs["originKey"])
server.socket = ctx.wrap_socket(server.socket, server_side=True)
thread = threading.Thread(target=lambda: _serve_loop(server, owned.stop), daemon=True, name="public-https")
owned.thread = thread
_call_thread_start(thread, fault_start)
owned.started = True
return owned
except Exception:
owned.close()
raise
class ConnectProxy:
def __init__(self) -> None:
self.events: list[dict[str, object]] = []
self.clients: list[socket.socket] = []
self.threads: list[threading.Thread] = []
self.stop = threading.Event()
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(16)
self.port = int(self.sock.getsockname()[1])
self.accept_thread = threading.Thread(target=self._accept, daemon=True, name="public-proxy-accept")
self.accept_started = False
try:
self.accept_thread.start()
self.accept_started = True
except Exception:
close_sock(self.sock)
for client in list(self.clients):
close_sock(client)
raise
def _handle(self, client: socket.socket) -> None:
dest = None
try:
buf = b""
while b"\r\n\r\n" not in buf:
chunk = client.recv(4096)
if not chunk:
return
buf += chunk
head, rest = buf.split(b"\r\n\r\n", 1)
lines = head.decode("latin1").split("\r\n")
method, target, *_rest = lines[0].split(" ")
headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1)
headers[k.strip().lower()] = v.strip()
authorized = headers.get("proxy-authorization") == basic()
self.events.append({"method": method, "target": target, "auth": authorized})
if not authorized:
client.sendall(
b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"lab\"\r\nContent-Length: 0\r\n\r\n"
)
return
if method == "CONNECT":
host, port_s = target.split(":")
dest = socket.create_connection((host, int(port_s)), timeout=5)
self.clients.append(dest)
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
_pipe(client, dest)
return
split = urlsplit(target)
dest = socket.create_connection((split.hostname, split.port or 80), timeout=5)
self.clients.append(dest)
path = split.path or "/"
if split.query:
path += "?" + split.query
dest.sendall(f"{method} {path} HTTP/1.1\r\nHost: {split.netloc}\r\nConnection: close\r\n\r\n".encode("latin1") + rest)
while True:
data = dest.recv(8192)
if not data:
break
client.sendall(data)
except OSError:
return
finally:
close_sock(client)
close_sock(dest)
def _accept(self) -> None:
self.sock.settimeout(0.3)
while not self.stop.is_set():
try:
client, _addr = self.sock.accept()
except socket.timeout:
continue
except OSError:
break
self.clients.append(client)
t = threading.Thread(target=self._handle, args=(client,), daemon=True, name="public-proxy-handler")
self.threads.append(t)
t.start()
def close(self) -> list[str]:
errors: list[str] = []
self.stop.set()
close_sock(self.sock)
for client in list(self.clients):
close_sock(client)
if self.accept_started:
self.accept_thread.join(JOIN_S)
if self.accept_thread.is_alive():
errors.append("accept-thread-alive")
for t in self.threads:
t.join(JOIN_S)
if t.is_alive():
errors.append(f"handler-alive:{t.name}")
return errors
def _pipe(a: socket.socket, b: socket.socket) -> None:
def one(src: socket.socket, dst: socket.socket) -> None:
try:
while True:
data = src.recv(8192)
if not data:
break
dst.sendall(data)
except OSError:
return
t1 = threading.Thread(target=one, args=(a, b), daemon=True)
t2 = threading.Thread(target=one, args=(b, a), daemon=True)
t1.start()
t2.start()
t1.join(JOIN_S)
t2.join(JOIN_S)
def python_https_with_ca(port: int, ca_pem: str, timeout_ms: int = TIMEOUT_MS) -> dict[str, object]:
deadline = time.monotonic() + timeout_ms / 1000.0
def remaining() -> float:
left = deadline - time.monotonic()
if left <= 0:
raise TimeoutError("total response deadline exceeded")
return left
ctx = ssl.create_default_context(cadata=ca_pem)
ctx.check_hostname = True
ctx.verify_mode = ssl.CERT_REQUIRED
raw = socket.create_connection(("localhost", port), timeout=remaining())
try:
raw.settimeout(remaining())
tls = ctx.wrap_socket(raw, server_hostname="localhost")
try:
tls.settimeout(remaining())
tls.sendall(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
data = b""
while True:
tls.settimeout(remaining())
chunk = tls.recv(8192)
if not chunk:
break
data += chunk
if len(data) > 1_048_576:
raise TimeoutError("response exceeded bound")
finally:
close_sock(tls)
finally:
close_sock(raw)
if b"\r\n\r\n" not in data:
raise TimeoutError("incomplete HTTP framing")
head, body = data.split(b"\r\n\r\n", 1)
status = int(head.decode("latin1").split(" ")[1])
headers: dict[str, str] = {}
for line in head.decode("latin1").split("\r\n")[1:]:
if ":" in line:
k, v = line.split(":", 1)
headers[k.strip().lower()] = v.strip()
if "content-length" in headers:
need = int(headers["content-length"])
if len(body) < need:
raise TimeoutError(f"incomplete body {len(body)}/{need}")
body = body[:need]
return {"status": status, "body": body.decode("utf-8", "replace"), "complete": True}
def _delete_tmp(tmp: str) -> None:
if not os.path.isdir(tmp):
return
for name in os.listdir(tmp):
if name.endswith(".key"):
try:
os.remove(os.path.join(tmp, name))
except OSError:
pass
shutil.rmtree(tmp, ignore_errors=True)
def _live_names(threads: list[threading.Thread]) -> list[str]:
return [t.name for t in threads if t is not None and t.is_alive()]
def _snapshot(ports: list[int], threads: list[threading.Thread]) -> dict[str, object]:
return {"listen": leftover_listen(ports), "threads": _live_names(threads)}
def run_public_fault(kind: str) -> int:
isolate_env()
tmp = tempfile.mkdtemp(prefix="pw-es-public-fault-")
http_owned = None
https_owned = None
proxy = None
idle_client = None
operation_error = None
ports: list[int] = []
try:
if kind == "tls":
certs = make_certs(tmp)
try:
https_owned = start_https_origin(certs, fault_tls=True)
except Exception as e: # noqa: BLE001
operation_error = redact(e)
elif kind == "http-start":
try:
http_owned = start_http_origin(fault_start=True)
except Exception as e: # noqa: BLE001
operation_error = redact(e)
elif kind == "proxy-start":
real = threading.Thread.start
def boom(th_self: threading.Thread, *a: object, **k: object) -> None:
threading.Thread.start = real
raise RuntimeError("inject-thread-start")
threading.Thread.start = boom # type: ignore[method-assign]
try:
proxy = ConnectProxy()
except Exception as e: # noqa: BLE001
operation_error = redact(e)
finally:
threading.Thread.start = real
elif kind == "shutdown":
http_owned = start_http_origin()
certs = make_certs(tmp)
https_owned = start_https_origin(certs)
ports = [http_owned.port, https_owned.port]
def boom_shutdown(*_a: object, **_k: object) -> None:
raise RuntimeError("inject-public-shutdown")
http_owned.server.shutdown = boom_shutdown # type: ignore[method-assign]
elif kind == "serverclose":
http_owned = start_http_origin()
ports = [http_owned.port]
def boom_close(*_a: object, **_k: object) -> None:
raise RuntimeError("inject-server-close")
http_owned.server.server_close = boom_close # type: ignore[method-assign]
elif kind == "idle-origin":
http_owned = start_http_origin()
ports = [http_owned.port]
idle_client = socket.create_connection(("127.0.0.1", http_owned.port), timeout=2)
time.sleep(0.2)
elif kind == "idle-https":
certs = make_certs(tmp)
https_owned = start_https_origin(certs)
ports = [https_owned.port]
ctx = ssl.create_default_context(cadata=certs["caPem"])
ctx.check_hostname = True
ctx.verify_mode = ssl.CERT_REQUIRED
raw = socket.create_connection(("localhost", https_owned.port), timeout=2)
idle_client = ctx.wrap_socket(raw, server_hostname="localhost")
time.sleep(0.2)
elif kind == "idle":
proxy = ConnectProxy()
idle_client = socket.create_connection(("127.0.0.1", proxy.port), timeout=2)
proxy.clients.append(idle_client)
ports = [proxy.port]
time.sleep(0.2)
else:
raise RuntimeError("unknown public fault")
except Exception as e: # noqa: BLE001
operation_error = operation_error or redact(e)
if http_owned is not None:
ports.append(http_owned.port)
if https_owned is not None:
ports.append(https_owned.port)
if proxy is not None:
ports.append(proxy.port)
ports = list(dict.fromkeys(ports))
cleanup_errors: list[str] = []
if http_owned is not None:
cleanup_errors.extend(http_owned.close())
if https_owned is not None:
cleanup_errors.extend(https_owned.close())
if proxy is not None:
cleanup_errors.extend(proxy.close())
close_sock(idle_client)
_delete_tmp(tmp)
threads: list[threading.Thread] = []
if http_owned is not None:
threads.extend(http_owned.live_threads())
if https_owned is not None:
threads.extend(https_owned.live_threads())
if proxy is not None:
threads.append(proxy.accept_thread)
threads.extend(proxy.threads)
immediate = _snapshot(ports, threads)
time.sleep(0.7)
after = _snapshot(ports, threads)
leftover = bool(immediate["listen"] or after["listen"] or immediate["threads"] or after["threads"])
alive_errors = [e for e in cleanup_errors if "alive" in e]
if operation_error is None:
for e in cleanup_errors:
if str(e).startswith("inject-"):
operation_error = str(e)
break
out = {
"fault": kind,
"operationError": operation_error,
"cleanupErrors": cleanup_errors,
"immediate": immediate,
"after700ms": after,
"leftoverListen": bool(immediate["listen"] or after["listen"]),
"cleaned": not leftover and not alive_errors,
}
print(json.dumps(out, indent=2, ensure_ascii=False))
return 0 if out["cleaned"] else 1
def main() -> int:
fault = os.environ.get("PLAYWRIGHT_ES_PUBLIC_FAULT", "")
if fault:
return run_public_fault(fault)
isolate_env()
tmp = tempfile.mkdtemp(prefix="pw-es-public-lab-")
http_owned = None
https_owned = None
proxy = None
browser = None
result: dict[str, object] = {"ok": False, "ignore_https_errors": False, "cases": []}
try:
from playwright.sync_api import sync_playwright
certs = make_certs(tmp)
http_owned = start_http_origin()
https_owned = start_https_origin(certs)
proxy = ConnectProxy()
http_port = http_owned.port
https_port = https_owned.port
with sync_playwright() as p:
browser = p.chromium.launch(
proxy={
"server": f"http://127.0.0.1:{proxy.port}",
"username": USER,
"password": PASSWORD,
},
timeout=30000,
)
context = browser.new_context(ignore_https_errors=False)
page = context.new_page()
page.set_default_timeout(TIMEOUT_MS)
http_res = page.goto(f"http://127.0.0.1:{http_port}/")
http_body = page.text_content("body")
result["cases"].append( # type: ignore[union-attr]
{
"name": "browser-http-via-proxy",
"status": http_res.status if http_res else None,
"body": http_body,
"proxyEvents": [
{"method": e["method"], "target": e["target"]}
for e in proxy.events
if e["method"] == "GET"
],
}
)
tls_err = None
try:
page.goto(f"https://127.0.0.1:{https_port}/")
except Exception as e: # noqa: BLE001
tls_err = {"name": type(e).__name__, "message": redact(e)}
result["cases"].append( # type: ignore[union-attr]
{
"name": "browser-https-untrusted-ca",
"error": tls_err,
"connectEvents": [e for e in proxy.events if e["method"] == "CONNECT"],
}
)
trusted = python_https_with_ca(https_port, certs["caPem"])
result["cases"].append({"name": "python-https-with-lab-ca", **trusted}) # type: ignore[arg-type]
context.close()
try:
browser.close()
except Exception:
pass
browser = None
cases = result["cases"] # type: ignore[assignment]
result["ok"] = (
cases[0]["status"] == 200
and tls_err is not None
and trusted["status"] == 200
and len(cases[1]["connectEvents"]) >= 1
)
result["httpOrigin"] = http_port
result["httpsOrigin"] = https_port
result["proxyPort"] = proxy.port
except Exception as e: # noqa: BLE001
print(redact(e), file=sys.stderr)
result["error"] = redact(e)
result["ok"] = False
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
cleanup_errors: list[str] = []
if http_owned is not None:
cleanup_errors.extend(http_owned.close())
if https_owned is not None:
cleanup_errors.extend(https_owned.close())
if proxy is not None:
cleanup_errors.extend(proxy.close())
_delete_tmp(tmp)
if cleanup_errors:
result["cleanupErrors"] = cleanup_errors
ports: list[int] = []
threads: list[threading.Thread] = []
if http_owned is not None:
ports.append(http_owned.port)
threads.extend(http_owned.live_threads())
if https_owned is not None:
ports.append(https_owned.port)
threads.extend(https_owned.live_threads())
if proxy is not None:
ports.append(proxy.port)
threads.append(proxy.accept_thread)
threads.extend(proxy.threads)
immediate = _snapshot(ports, threads)
time.sleep(0.7)
after = _snapshot(ports, threads)
result["immediate"] = immediate
result["after700ms"] = after
leftover = bool(immediate["listen"] or after["listen"] or immediate["threads"] or after["threads"])
result["leftoverListen"] = bool(immediate["listen"] or after["listen"])
result["leftoverHelpers"] = leftover
if leftover:
result["ok"] = False
print(json.dumps(result, indent=2, ensure_ascii=False))
if result.get("ok") and not result.get("cleanupErrors") and not result.get("leftoverHelpers"):
return 0
return 1
if __name__ == "__main__":
sys.exit(main())
05SOCKS5 solo si el motor lo documenta y se prueba
La documentación de red dice que las páginas pueden cargar por proxy HTTP o SOCKSv5. Chromium 156.0.8078.4 con server: "socks5://127.0.0.1:<puerto>" sin usuario trajo el origen HTTP con estado 200. El fixture SOCKS escribió CONNECT 127.0.0.1:<puerto-origen>. SOCKS5 sin autenticación entra en el recorrido de este pin para Chromium.
SOCKS5 con autenticación no. chromium.launch, firefox.launch y webkit.launch en Playwright 1.64.0 lanzaron todos Error: Browser does not support socks5 proxy authentication. El fallo ocurrió en el launch, antes de page.goto. Playwright 1.64.0 tenía Firefox 157.0 y WebKit 27.2 descargados para esos arranques. browser.version() quedó vacío porque el launch no llegó. El mensaje coincide con la petición abierta microsoft/playwright#10567, el 14 de octubre de 2026 todavía Open con etiqueta P3-collecting-feedback. Un comentario de maintainer del 28 de noviembre de 2021 apunta que Chromium y Firefox no hacen userpass SOCKS5 en el propio motor.
Si un proveedor solo ofrece SOCKS5 con usuario, este pin de Playwright no manda ese usuario. Usa un proxy HTTP con username y password, o un SOCKS5 sin autenticación que el motor haya elegido de verdad en un recorrido como el de arriba. El tipo de salida, residencial o móvil o datacenter, se elige en el catálogo, no en el dict proxy.
06Aislar dos contexts
browser.new_context() crea un tarro que no comparte cookies con otros context. Combínalo con proxy por context cuando dos recorridos no deben dejar la sesión en el exit equivocado.
- Arranca Chromium sin proxy de browser.
- Context A con proxy A, context B con proxy B.
- En ambos,
page.gotoal mismo origen. - Compara cookies.
En el laboratorio las dos navegaciones volvieron 200. Los valores de cookie lab= no coincidieron. context.cookies() de los dos tarros tampoco era idéntico. El origen había dado a cada context su propio token. Un tarro compartido habría devuelto el mismo token.
El aislamiento no es magia contra fugas de la app. Separa el estado de Playwright. Si la página bajo prueba ata la sesión en LocalStorage a un id que tú mismo fijas, el segundo context no ayuda. Mide los tarros, no la intención.
Cierra los dos context en explícito. Si no, browser.close() se los lleva y no vacía HAR o vídeo con orden.
07Diagnosticar 407, rechazo y certificados sin desactivar TLS
Los fallos se parecen hasta que separas clase, estado y log del proxy. Un puerto rechazado http://127.0.0.1:1 entregó net::ERR_PROXY_CONNECTION_FAILED. El listener mudo entregó TimeoutError a los 8000 ms. La Basic ausente y la Basic falsa entregaron 407 como estado, sin throw. La CA de laboratorio no confiable entregó ERR_CERT_AUTHORITY_INVALID después de CONNECT. Python sin CA entregó SSLCertVerificationError. Son cinco capas distintas.
| Capa | Dónde se pone | Protocolo en el laboratorio | Límite del motor en 1.64.0 | Error típico |
|---|---|---|---|---|
| Launch del browser | chromium.launch(proxy=...) | Forward HTTP, CONNECT para HTTPS | vale para todos los context sin override | ERR_PROXY_CONNECTION_FAILED contra el puerto 1 |
| Context | browser.new_context(proxy=...) | HTTP, sustituye el launch | cookies separadas | 200 en el proxy B, el proxy de launch no ve el host |
| Auth HTTP | username / password en el proxy HTTP | Basic, dummy | documentado para HTTP, no userpass SOCKS5 | 407 missing, 407 wrong, 200 correcto |
| SOCKS5 sin auth | server: socks5://... | CONNECT en el log SOCKS | Chromium 200 en este pin | launch ok |
| SOCKS5 con auth | username más socks5:// | no se ejecutó | error de launch en Chromium, Firefox y WebKit | Browser does not support socks5 proxy authentication |
| TLS | Default ignore_https_errors=False | CONNECT, luego confianza del browser | no hay archivo de CA en Playwright | ERR_CERT_AUTHORITY_INVALID; Python con CA 200 |
bypass: "127.0.0.1" saltó el proxy, estado 200, log del proxy vacío. bypass: "example.com" con destino 127.0.0.1 siguió por el proxy. Bypass es la lista documentada de hosts, no un escape general.
Python ssl contra un HTTPS de laboratorio que aceptaba el handshake y callaba el cuerpo lanzó TimeoutError a los 8003 ms. El mismo cliente contra un origen que goteaba un byte por segundo también cortó a los 8001 ms, sin devolver un 200 truncado. El plazo total cubre connect, handshake y cuerpo. No basta con un timeout solo en el socket crudo.
Si el servicio se queda mudo, el proxy no funciona acota la capa antes de reescribir el cliente. Para la lista más larga, cómo comprobar si un proxy funciona. Las salidas residencial y móvil se distinguen por el origen de la IP, no por el dict proxy. Las páginas proxies residenciales, proxies móviles y datacenter filtran el tipo. Playwright sigue siendo la misma biblioteca.

SOAX vende proxies residenciales y móviles rotativos en un pool compartido de créditos, con datacenter e ISP en los mismos planes. Builder empieza en $200/mes + IVA (200 créditos); el tráfico Tier 1 es $3/GB en Builder.

Bright Data es una gran red de proxies para recopilar datos: IP residenciales, móviles, ISP y de datacenter en unos 195 países. El tráfico residencial se puede pagar por uso sin suscripción, y las direcciones de datacenter e ISP se venden en paquetes desde 10 IP. El acceso residencial y móvil exige verificar la identidad.

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.

Decodo (antes Smartproxy) vende proxies residenciales y de datacenter. Residencial: $4/GB sin plan mensual, o paquetes mensuales desde $3.75/GB por 3 GB. Pagos: tarjetas, PayPal, Alipay, Google/Apple Pay, cripto (con límites).
Las tarjetas de abajo son fichas del catálogo en español. Para Playwright 1.64.0 hace falta un proxy HTTP con CONNECT hacia destinos HTTPS y login en username/password, o SOCKS5 sin usuario. SOCKS5 con usuario no encaja en este pin, aunque el proveedor escriba SOCKS5. Si el endpoint elegido habla así, está en la documentación actual del proveedor, no en este recorrido de bucle local. Bright Data menciona Playwright en su ficha ES. Eso no sustituye un trace contra tu host.
¿Pongo el proxy en el browser o en el context?
¿Por qué el destino es https y el proxy es http?
¿Sirve http_credentials para el proxy?
¿Puede Playwright usar SOCKS5 con usuario?
¿Puedo encender ignore_https_errors hasta que el certificado encaje?
¿Qué es bypass?
¿Un 200 local significa que el proveedor funciona?
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.
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.



