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.

7 min readTested on requests 2.34 · urllib3 2.8
requests sends CONNECT to the proxy; the proxy answers 407, 403 or 465 and the request never reaches the website.The number comes from the proxy, not the websiterequestsyour scriptProxyanswers hereWebsitenever reachedCONNECT407Tunnel connection failed:407login missing or wrongTunnel connection failed:403username the proxy can’t readTunnel connection failed:465no exit for these filtersReproduced on our gateway with requests 2.34

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.

407 · login403 · username format465 · no exit

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:

txt
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 wronghttps:// websitehttp:// website
Wrong password, or no login at allProxyError … 407r.status_code == 407
A targeting value the gateway can’t readProxyError … 403r.status_code == 403
Filters that no exit matchesProxyError … 465r.status_code == 465
Fig. 1 — requests 2.34 against via.proxies.vision:9000, October 14, 2026.

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":

403USER-country-usathree-letter code
200USER-country-uscountry
403USER-country-us-session-job1-lifetime-10 on :9000session on the rotating port
200USER-country-us-session-job1-lifetime-10 on :10000sticky port, 10 minutes

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.

465USER-country-de-city-chicagono Chicago in Germany
200USER-country-us-city-chicagocountry and city agree

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.

fetch.py
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://?
Most often because the credentials are in a Proxy-Authorization header you set yourself. requests sends your headers to the website, inside the tunnel, not to the proxy with the CONNECT request. Put the username and password into the proxy URL instead.
Why do I get a 407 response instead of an exception?
For an http:// website there is no tunnel. The proxy's answer comes back as a normal response, so requests returns it and r.status_code is 407. Only https:// websites need a tunnel, and a refused tunnel raises ProxyError.
The text after the status code is unreadable. Is that a problem?
No. After the number comes the proxy's reason phrase, which every proxy words differently and which may not be in English or plain ASCII. Only the number matters.
Does the same happen in httpx?
Yes, with a shorter message: httpx.ProxyError: 407. The causes are the same, including the header: a Proxy-Authorization header set on the request gave us 407 in httpx too. Put the login into the proxy URL.
What about 'Tunnel connection failed: 403 Forbidden'?
On our gateway a 403 means the login was accepted but the username asks for something it can't do: a value it can't read (a three-letter country code, a space in a city), a session on a rotating port, or a lifetime over 24 hours. On other proxies 403 can also mean a destination the operator blocks. For an http:// website the response body names the part that is wrong.

Residential proxies, pay per GB

HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.