ERR_TUNNEL_CONNECTION_FAILED means Chrome asked the proxy to open a tunnel and the proxy said no. To load https://example.com through an HTTP proxy, Chrome first sends CONNECT example.com:443 HTTP/1.1 to the proxy and needs 200 back before a single byte of TLS goes out. A 403 from a blocklist, a 502 from a gateway whose upstream fell over and a 504 from an exit that never answered all end on the same grey page. None of them is about the site. In our own runs through residential gateways, the one we met most was a 504 from a sticky session the gateway had already retired.

Where ERR_TUNNEL_CONNECTION_FAILED comes from in Chrome

Chromium numbers its network errors in one file, net/base/net_error_list.h, and this one is -111: "A tunnel connection through the proxy could not be established". The decision is made in HttpProxyClientSocket, in http_proxy_client_socket.cc, after it reads the proxy's reply headers. It switches on the status code. 200 opens the tunnel and 407 sends Chrome off to find proxy credentials. The default: branch, which is every other code from 301 to 504, returns -111. Two odd cases land there too: a 200 followed by bytes the proxy should not have sent yet, and a reply older than HTTP/1.0.

Three neighbours in the same file look similar and point at different things:

ErrorWhat failed
ERR_PROXY_CONNECTION_FAILED (-130)Reaching the proxy at all: its name did not resolve, or the socket to it would not open
ERR_TUNNEL_CONNECTION_FAILED (-111)The proxy answered, but the CONNECT came back as something other than 200
ERR_PROXY_AUTH_UNSUPPORTED (-115)The proxy asked for a login by a method Chrome does not speak
ERR_SOCKS_CONNECTION_FAILED (-120)The SOCKS case: the connection through a SOCKS proxy to the target host failed

A -111 therefore tells you more than it seems to: the proxy resolved, accepted the socket and wrote an HTTP reply. What it wrote is the part you cannot see. If you are not sure the port speaks HTTP at all, the port number names the protocol shows how we asked one seller's ports which protocol each spoke.

Paste one gateway line. Every profile mints its own exitIn Mosaic: Provider minting. One gateway line, a separate exit for every profile. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Provider minting

Why Chrome hides the proxy's reason

Gateways usually explain a refusal. A 504 arrives with a page saying the upstream timed out, and a 403 often names the rule that matched. Chrome reads that page's headers and throws the body away, on purpose.

The default: branch carries the reason in a comment that points at crbug 137891: the body is ignored "to avoid letting the proxy impersonate the target server". The address bar would show your bank's name over a page the proxy wrote. The next lines of that comment concede what it costs. Chromium's developers had seen 403, 404 and 501 bodies with a useful message in them, and Squid, for one, reports a failed DNS lookup as a 404 reading "The domain name does not exist." That message dies at this line.

curl has no such rule, so put the same proxy in front of curl and read the reply yourself:

curl -v -x http://user:pass@gate.example:8000 https://example.com/ -o /dev/null

In the -v output the CONNECT reply is the < HTTP/1.1 line that comes before any TLS, for example < HTTP/1.1 504 Gateway Timeout, followed by whatever headers the gateway added. Inside Chrome, a capture from chrome://net-export holds the same headers under the event HTTP_TRANSACTION_READ_TUNNEL_RESPONSE_HEADERS, which is the browser's own record of what it received.

The cause we kept hitting: a session the gateway had retired

Most fix-it pages for this error tell you to switch off the system proxy, which helps only if you never meant to have one. With a residential gateway and sticky sessions, look at the username instead.

Ours carried a token like -session-<hex>-lifetime-60, which asks the gateway to keep one exit for sixty minutes. After the sixty minutes the gateway stops honouring it, and nothing about the failure says "expired". During one measurement run the browser logged net::ERR_TUNNEL_CONNECTION_FAILED and net::ERR_ABORTED, and four sites in a row came back unreachable. We read that as four third-party sites having a bad afternoon. Our proxy checker, run a minute later, called the proxy healthy, with an exit in Walnut Ridge, Arkansas. An hour earlier the same profile had been coming out in Mililani Town, Hawaii. The session had aged out, and in between the gateway had been refusing the stale token.

The other shape is a dead exit. A residential pool hands out the occasional session that reaches nothing. When we went looking, we minted forty fresh sessions at one seller and the twenty-ninth was bad: it answered 504 four times in a row, on the original attempt and on three retries. The next fresh session we tried also answered 504, and the two after it answered 200. A 504 there is a property of the session, not of the gateway.

Both cases taught us something about reading the error. The four unreachable sites in the Arkansas run looked like four outages, and the cheaper explanation was one proxy, so now a cluster of failures through one exit sends us to the proxy first. That run also lasted longer than the session's sixty minutes, which made it two experiments on two addresses; a long job wants a session lifetime longer than the job, since every site you are signed in to sees the address change (how accounts get linked is about exactly that). And the 504 run showed what a retry is worth on a sticky session: session 889053 failed four times because each retry asked the same dead exit. The fix is a new session id.

How Mosaic handles a refused tunnel

Mosaic runs the same CONNECT itself before every launch, and a profile does not open behind a proxy that has not answered and tunnelled. Because the check is our code and not Chrome's, it keeps the status line Chrome would discard, so the refusal reads "Proxy returned HTTP 504" instead of a -111 in an empty tab.

What happens next depends on who made the session id. Save a seller's gateway line once as a provider, and each profile mints its own session from it; a dead one of those is swapped for a fresh session with the same pick, so a profile set to Germany still comes out in Germany, and the working token is written back so the next launch does not start on the dead one. A session id you typed yourself is never swapped. That launch is refused, because a profile that quietly came out somewhere else would be worse than one that did not open. A session token on a rotating port covers how the port and the username decide whether a session sticks in the first place.