We got a report that read like a proxy bug: "HTTP/2 to google.com through your gateway hangs after the TLS handshake. HTTP/1.1 works." Every detail pointed at us. Only HTTP/2, so maybe we mangle h2. Only through the proxy, so maybe the tunnel stalls. Stock Chrome was reportedly affected too.
It wasn't the proxy. It was an HTTP/2 frame called ACCEPT_CH, Chrome's reaction to it, and the way Playwright handles proxy credentials. Here is how we got there, and the two fixes.
The symptom, reproduced
Playwright 1.63, its bundled Chromium 153, headless, proxy with a username and password:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
"/tmp/profile", headless=True,
proxy={"server": "http://gw.example.com:41080",
"username": USER, "password": PASS})
page = ctx.new_page()
page.goto("https://www.google.com/", timeout=25000)
plain TimeoutError: Page.goto: Timeout 25000ms exceeded 25.0s
plain TimeoutError: Page.goto: Timeout 25000ms exceeded 25.0s
Every time. Now the controls, same proxy, same exit country:
| Run | Result |
|---|---|
| google.com, defaults | timeout, 25s |
google.com, --disable-http2
|
200, 6.9s |
| bing.com, defaults | 200, 6.2s |
google.com, --disable-features=AcceptCHFrame
|
200, 5.7s |
| google.com, credentials injected by a local forwarder | 200, 4.7s |
Only Google, only HTTP/2, only when Playwright itself answers the proxy's auth challenge.
Ruling out the tunnel
Before reading browser internals, prove the path. Our gateway relays bytes after CONNECT; it never looks inside TLS, so h2 and HTTP/1.1 are the same thing to it. But "it can't be us" is not evidence, so:
curl --http2 -x http://USER:PASS@gw.example.com:41080 \
-o /dev/null -w '%{http_code} %{http_version}\n' https://www.google.com/
# 200 2
A Node http2 client through the same tunnel also got the server SETTINGS, a 200 and 70KB of HTML. A headed Chrome launched by hand through the proxy loaded the page over a single h2 session with 20+ streams. The tunnel carries h2 fine. Whatever hangs is inside the automated browser.
What the net-log shows
Chrome will tell you exactly what it did if you ask:
chrome --proxy-server=http://127.0.0.1:18081 --log-net-log=/tmp/net.json https://www.google.com/
Open the file in the netlog viewer and follow the main-frame request. In the run that hangs, the order is:
- The h2 session to www.google.com comes up through the proxy.
-
HTTP2_SESSION_RECV_ACCEPT_CH: Google tells Chrome which client hints it wants (Sec-CH-UA-Arch,-Bitness,-Model, and so on). - The main-frame request ends with net error
-3,ERR_ABORTED. - Nothing. In the hanging run, no second navigation request appears until Playwright's timeout fires.
Step 3 looks alarming but it is deliberate. Chrome normally learns which client hints a site wants from the Accept-CH response header, and only sends them from the next request on. The ACCEPT_CH frame lets the server say so during connection setup instead, before the first request goes out. If Chrome has already started the request without those hints, it cancels it and restarts the navigation with the hints attached. In a hand-launched Chrome the log shows exactly that: -3, then an immediate retry that succeeds. Google is the main site that sends this frame, which explains "only Google". HTTP/1.1 has no equivalent frame, which explains "only h2".
So the abort is normal. What's wrong is that the restarted navigation never goes out.
Why the restart gets lost
When you give Playwright a proxy with username and password, Chromium is pointed at the proxy without credentials, and Playwright answers the 407 challenge itself through the DevTools protocol's request interception. That is the only thing that differs between the runs that hang and the runs that don't: same browser build, same proxy, same exit. Hand Chromium a proxy that needs no auth (the forwarder below) and the restart goes through.
We did not chase it further into Chromium's source. What we can say from the logs: the browser-initiated restart and the interception layer don't meet, and page.goto waits for a response that will never come. If you have seen the inside of this, I would like to read about it.
Fix 1: turn off the frame
ctx = p.chromium.launch_persistent_context(
"/tmp/profile", headless=True, proxy=PROXY,
args=["--disable-features=AcceptCHFrame"])
You keep HTTP/2. Chrome stops acting on the frame, so there is nothing to restart, and it still gets client hints the old way, from the Accept-CH response header. This is the one-line fix and the one we'd try first.
--disable-http2 also works, and it is what people usually find first. It costs you multiplexing on every site to work around one frame on one site.
Fix 2: take proxy auth out of the browser
If you would rather not depend on a feature flag, give the browser a proxy that needs no credentials. A forwarder on localhost adds the Proxy-Authorization header and passes everything else through:
import asyncio, base64, os
LISTEN = ("127.0.0.1", 18081)
UP_HOST, UP_PORT = os.environ["UPSTREAM"].rsplit(":", 1)
TOKEN = base64.b64encode(
f"{os.environ['PROXY_USER']}:{os.environ['PROXY_PASS']}".encode()).decode()
async def pipe(reader, writer):
try:
while data := await reader.read(65536):
writer.write(data)
await writer.drain()
except ConnectionError:
pass
finally:
writer.close()
async def handle(c_reader, c_writer):
head = await c_reader.readuntil(b"\r\n\r\n")
lines = head.decode("latin-1").split("\r\n")
lines = [l for l in lines if not l.lower().startswith("proxy-authorization:")]
lines.insert(1, f"Proxy-Authorization: Basic {TOKEN}")
u_reader, u_writer = await asyncio.open_connection(UP_HOST, int(UP_PORT))
u_writer.write("\r\n".join(lines).encode("latin-1"))
await u_writer.drain()
await asyncio.gather(pipe(c_reader, u_writer), pipe(u_reader, c_writer))
async def main():
async with await asyncio.start_server(handle, *LISTEN) as server:
await server.serve_forever()
asyncio.run(main())
Then proxy={"server": "http://127.0.0.1:18081"}, no username or password. That run is the 4.7s row in the table. The sketch only rewrites the first request on each connection, which covers HTTPS: browsers open a CONNECT tunnel per connection, and after that it's all bytes. If you send plain-HTTP requests through it with keep-alive, rewrite every request head, not just the first.
The same trick helps elsewhere. Chrome 137 and later ignore --load-extension in branded builds, so the old "auth helper extension" trick is gone; a local forwarder works with any browser and any automation tool.
What to take away
- A hang that shows up only on HTTP/2, only on Google, and only under automation is very likely this. Check for
RECV_ACCEPT_CHfollowed by-3in a net-log before you look at the network. - Prove the path with something that isn't the browser (
curl --http2, a Nodehttp2client) before you blame the proxy or the tunnel. -
ERR_ABORTEDon the main frame is not always a failure. Look at what was supposed to happen next.
We run a residential proxy service, so "is it the proxy?" lands on our desk a lot; this one was a good reminder to measure first. Our Playwright and Puppeteer proxy guide covers the rest of the setup. The forwarder is in our examples repo.
Top comments (0)