Fixing Tunnel connection failed: 407 in Python requests
The number is the proxy’s answer, not the website’s. 407 means the proxy didn’t get a login it accepts, and there are five ways that happens.
In short
Put the proxy login into the proxy URL, escape special characters in the password, and check that no HTTPS_PROXY variable is overriding your code. A Proxy-Authorization header you set yourself never reaches the proxy for https:// sites.
To reach an https:// website through a proxy, requests first sends the proxy one small request: CONNECT example.com:443, with your proxy login. If the proxy agrees, it opens a tunnel and everything after that is encrypted traffic between you and the website. If it doesn’t, it answers with a status code, and requests raises:
requests.exceptions.ProxyError: HTTPSConnectionPool(host='ipinfo.io', port=443): Max retries exceeded with url: /ip (Caused by ProxyError('Unable to connect to proxy', OSError('Tunnel connection failed: 407 …')))The website never saw the request. So the status tells you about your proxy login or your proxy settings, never about the site. After the number comes the proxy’s own reason phrase, which may be “Proxy Authentication Required” or something unreadable; only the number matters.
For an http:// website there is no tunnel, and the same refusal arrives as an ordinary response. No exception is raised. We sent each broken login both ways:
| What was wrong | https:// website | http:// website |
|---|---|---|
| Wrong password, or no login at all | ProxyError … 407 | r.status_code == 407 |
| A targeting value the gateway can’t read | ProxyError … 403 | r.status_code == 403 |
| Filters that no exit matches | ProxyError … 465 | r.status_code == 465 |
That second column is a trap of its own: a scraper that only checks for exceptions will happily save the proxy’s error page as if it were the website. Check r.status_code, or call r.raise_for_status().
407: five causes
All five produced the same message in our tests. Go through them in this order.
1. The login is wrong or missing
A typo, a password that was reset, or a proxy URL without USER:PASS@. Copy both values again from your dashboard. On our gateway, targeting goes into the username (USER-country-us); the password stays as it is.
2. The login is in a header
Setting headers={"Proxy-Authorization": "Basic …"} looks right and works for http:// websites. For https:// websites, requests sends your headers to the website through the tunnel. The CONNECT request that opens the tunnel goes out without them, and the proxy answers 407. In our test the same header gave 200 for an http:// URL and 407 for an https:// one. Put the login into the proxy URL.
3. An environment variable wins
requests reads HTTP_PROXY and HTTPS_PROXY from the environment, and for a Session they take priority over session.proxies. We set a correct proxy on the session and a stale one in HTTPS_PROXY: four of four requests failed with 407. Passing proxies= to each request, or setting session.trust_env = False, gave four of four successes. Look for leftovers with env | grep -i proxy.
4. A special character in the password
An @ in the password moves the boundary between login and host, and the proxy receives a different password: 407. A # cuts the URL off and you get InvalidURL: Failed to parse instead. Escape both parts with urllib.parse.quote(value, safe="").
5. The proxy entry isn’t the one being used
The proxies dictionary is keyed by the website’s scheme. With only an "http" entry, an https:// request doesn’t use your proxy at all: it goes out directly, or through whatever HTTPS_PROXY says. Set both keys to the same proxy URL.
A 403 from our gateway means the login was fine but the username asks for something the gateway can’t do. Targeting options are written into the username, and the gateway checks each value. For an http:// website the body of the 403 names the part it rejected, for example Invalid "country":
A key it doesn’t know fails silently
The gateway refuses values it can’t read, but it ignores keys it doesn’t know. USER-cc-US, the way some other providers write a country, gave 200 twice in our test, with exits in Canada and Kuwait. No error, just the wrong country. If your exits land in random places, check the spelling of each key against your dashboard.
465 is not a standard HTTP status; our gateway uses it to say the login was fine and it found no exit to send you through. That happens when the filters contradict each other or describe a place with no exits online. The gateway fails instead of quietly giving you another location.
Remove the narrowest filter first, usually the city. A 465 on a request that worked a minute ago, with the same username, is a single exit that went away: retry it.
In a headless browser
Chromium reports all of these refusals with one code, ERR_TUNNEL_CONNECTION_FAILED. The Puppeteer version of this article shows how to get the status back.
A setup that avoids all five
Login in the URL, escaped, on a session that ignores the environment.
import os
from urllib.parse import quote
import requests
USER = os.environ["PROXY_USER"]
PASS = os.environ["PROXY_PASS"]
# quote() escapes @ : / # and friends, so the URL still parses.
proxy = f"http://{quote(USER, safe='')}:{quote(PASS, safe='')}@via.proxies.vision:9000"
session = requests.Session()
session.trust_env = False # ignore HTTP_PROXY / HTTPS_PROXY from the environment
session.proxies = {"http": proxy, "https": proxy}
r = session.get("https://ipinfo.io/ip", timeout=30)
print(r.status_code, r.text.strip())If this fails with something other than a tunnel status, the proxy wasn’t reached at all: every cause of “Cannot connect to proxy”.
Why does my proxy work for http:// URLs but fail with 407 for https://?
Why do I get a 407 response instead of an exception?
The text after the status code is unreadable. Is that a problem?
Does the same happen in httpx?
What about 'Tunnel connection failed: 403 Forbidden'?
Keep reading
aiohttp Proxy Errors: ClientHttpProxyError 407, SOCKS5 and trust_env
aiohttp's ClientHttpProxyError explained, why its message leaks your proxy password, socks5:// without aiohttp-socks, and rotation.
6 min read · #python#errors
Scrapy: Could not open CONNECT tunnel with proxy (407, 403, SOCKS5)
What the status in Scrapy's TunnelError means, why socks5:// only times out, and a proxy middleware that works. Every case reproduced.
7 min read · #scrapy#python
Missing dependencies for SOCKS support: the Fix, and Why You See It Without SOCKS
Install requests[socks], or find the environment variable that asked for SOCKS. Plus socks5 vs socks5h and the errors that come next.
6 min read · #python#socks5
Residential proxies, pay per GB
HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.