Cómo configurar un proxy en Playwright

12 min de lectura

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.

  1. Decide si el recorrido pide un solo exit o dos identidades.
  2. Para un exit, pasa proxy a launch.
  3. Para dos exits, arranca el browser sin segunda identidad y luego un proxy distinto por context.
  4. 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í:

  1. Solo server, sin usuario. page.goto al origen HTTP devolvió estado 407. El proxy escribió authReason: missing. Playwright no lanzó excepción. El estado del objeto de respuesta fue la prueba.
  2. Usuario lab-user y una contraseña falsa. Otra vez estado 407. El log mostró authReason: wrong.
  3. 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.

Tabla de afirmaciones del laboratorio de bucle local de Playwright Python del 14 de octubre de 2026

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.

  1. Arranca Chromium sin proxy de browser.
  2. Context A con proxy A, context B con proxy B.
  3. En ambos, page.goto al mismo origen.
  4. 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.

CapaDónde se poneProtocolo en el laboratorioLímite del motor en 1.64.0Error típico
Launch del browserchromium.launch(proxy=...)Forward HTTP, CONNECT para HTTPSvale para todos los context sin overrideERR_PROXY_CONNECTION_FAILED contra el puerto 1
Contextbrowser.new_context(proxy=...)HTTP, sustituye el launchcookies separadas200 en el proxy B, el proxy de launch no ve el host
Auth HTTPusername / password en el proxy HTTPBasic, dummydocumentado para HTTP, no userpass SOCKS5407 missing, 407 wrong, 200 correcto
SOCKS5 sin authserver: socks5://...CONNECT en el log SOCKSChromium 200 en este pinlaunch ok
SOCKS5 con authusername más socks5://no se ejecutóerror de launch en Chromium, Firefox y WebKitBrowser does not support socks5 proxy authentication
TLSDefault ignore_https_errors=FalseCONNECT, luego confianza del browserno hay archivo de CA en PlaywrightERR_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: proxies residenciales y móviles
SOAX: proxies residenciales y móviles3.9219

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.

85 países
3,00 US$
por 1 GB
Bright Data: proxies residenciales y de datacenter
Bright Data: proxies residenciales y de datacenter4.41438

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.

10 países
5,00 US$
por 1 GB
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
Decodo (Smartproxy): proxies residenciales y de datacenter
Decodo (Smartproxy): proxies residenciales y de datacenter4.22107

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).

83 países
0,38 US$
por 1 GB

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?

En el browser si cada context debe usar el mismo exit. En el context si un proceso necesita dos exits o dos tarros de cookies. El proxy del context sustituye el valor de launch, no se mezcla.

¿Por qué el destino es https y el proxy es http?

Playwright manda CONNECT por el proxy HTTP y habla TLS dentro del túnel. En el laboratorio apareció CONNECT example.com:443 y después estado 200 con la verificación encendida.

¿Sirve http_credentials para el proxy?

No. http_credentials vale para el origen. El login del proxy va en proxy.username y proxy.password. Un 401 del origen y un 407 del proxy son saltos distintos.

¿Puede Playwright usar SOCKS5 con usuario?

En 1.64.0 Chromium, Firefox y WebKit lanzaron en el launch Browser does not support socks5 proxy authentication. SOCKS5 sin autenticación en Chromium pasó con 200.

¿Puedo encender ignore_https_errors hasta que el certificado encaje?

No como caso normal. El origen de laboratorio falló con ERR_CERT_AUTHORITY_INVALID después de CONNECT. Python con la CA de prueba devolvió 200. ignore_https_errors=True no fue un caso.

¿Qué es bypass?

Una lista de hosts separados por comas que no usan este proxy. 127.0.0.1 en la lista fue directo. El mismo origen sin estar en la lista pasó por el proxy.

¿Un 200 local significa que el proveedor funciona?

No. El recorrido prueba cliente, CONNECT, auth y TLS contra bucle local. Geo, cuenta y exit del proveedor son otras preguntas.