requests.exceptions.ProxyError means the connection to your proxy failed — not that the target site blocked you. The proxy was unreachable, refused the tunnel, or answered with something that is not a proxy response. Which of the three it was is written in the message, after the words "Caused by".
What ProxyError actually is
In Requests, ProxyError is a subclass of ConnectionError, which is itself a RequestException. Catching requests.exceptions.ConnectionError catches proxy failures too; catching ProxyError alone does not catch every proxy-shaped failure, which is the trap we measure below.
The message is three layers deep and almost everyone reads the wrong one:
requests.exceptions.ProxyError: HTTPSConnectionPool(host='example.com', port=443):
Max retries exceeded with url: /
(Caused by ProxyError('Unable to connect to proxy',
NewConnectionError("HTTPSConnection(host='127.0.0.1', port=54056):
Failed to establish a new connection: [Errno 61] Connection refused")))
- HTTPSConnectionPool(host='example.com') is the site you asked for. It is not the thing that failed, and it never saw your request.
- Max retries exceeded is urllib3's retry wrapper reporting that it gave up. It does not mean you should add retries.
- Caused by ProxyError(...) is the real diagnosis, and the innermost exception —
NewConnectionError,NameResolutionError,SSLError,OSError— is the cause you have to fix.
Ten causes, and the exact message each one prints
We ran ten isolated failure modes against a local fake proxy whose behaviour we controlled, so exactly one thing was wrong in each run. Environment: macOS, Python 3.13.6, OpenSSL 3.6.3, Requests 2.34.2 with urllib3 2.7.0, 8 September 2026. Every message below is copied from the run, not from memory.
| What is actually wrong | What Requests raises | The part that identifies it |
|---|---|---|
| Nothing is listening on the proxy port | ProxyError | NewConnectionError ... [Errno 61] Connection refused |
| The proxy hostname does not resolve | ProxyError | NameResolutionError ... Failed to resolve |
https:// scheme in front of an HTTP proxy | ProxyError | Your proxy appears to only use HTTP and not HTTPS + [SSL: WRONG_VERSION_NUMBER] |
| Bad or missing credentials, HTTPS target | ProxyError | OSError('Tunnel connection failed: 407 Proxy Authentication Required') |
| Bad or missing credentials, plain HTTP target | nothing — a 407 response | no exception at all; r.status_code == 407 |
| The port answers HTTP but is not a proxy | ProxyError | OSError('Tunnel connection failed: 400 Bad Request') |
| The proxy accepts the socket, then closes it | ConnectionError | ('Connection aborted.', ConnectionResetError(54, ...)) |
| The proxy accepts the socket and never answers | ReadTimeout | Read timed out. (read timeout=4) |
socks5:// without the SOCKS extra installed | InvalidSchema | Missing dependencies for SOCKS support. |
An HTTPS_PROXY variable you forgot was exported | ProxyError | identical to the first row — with no proxies= in your code |
Three results are worth stating plainly. Only six of the ten raise ProxyError on a current stack. One raises nothing at all. And the two that changed class — reset and silence — used to be ProxyError.
The urllib3 2.x change that quietly breaks "except ProxyError"
We re-ran the same ten scenarios on Requests 2.28.2 with urllib3 1.26.20, and again on urllib3 2.0.7. The 2.0.7 and 2.7.0 results are identical to each other, so the behaviour changed at the 2.0 boundary and has been stable since.
| Scenario | urllib3 1.26.20 | urllib3 2.0.7 and 2.7.0 |
|---|---|---|
| Proxy accepts the socket, then resets it | ProxyError('Cannot connect to proxy.', ConnectionResetError) | ConnectionError('Connection aborted.', ConnectionResetError) |
| Proxy accepts the socket, never answers CONNECT | ProxyError('Cannot connect to proxy.', TimeoutError) | ReadTimeout: Read timed out. |
| Wording of every other proxy failure | Cannot connect to proxy. | Unable to connect to proxy (no full stop) |
urllib3's 2.0.0 changelog says ProxyError was changed to wrap any connection error — timeout, TLS, DNS — that occurs when connecting to the proxy. Our runs show where that boundary now sits: failures while the socket to the proxy is being established stay ProxyError; failures after it is open belong to the request, and surface as ConnectionError or ReadTimeout.
Two practical consequences. If your retry or rotation code catches ProxyError only, upgrading urllib3 silently stops retrying the two most common symptoms of an overloaded proxy pool — catch requests.exceptions.RequestException, or ConnectionError and Timeout together. And if you string-match the message, matching "Cannot connect to proxy." matches nothing on a modern install.
Fix it by cause
The https:// scheme in front of an HTTP proxy
This is the single most common self-inflicted case, and the one behind the top-ranked Stack Overflow question for this error. Both keys of the dict take an http:// URL. The key selects which target scheme uses the proxy; the value is how you talk to the proxy itself, and almost every commercial proxy speaks plaintext HTTP on its endpoint:
proxies = {
"http": "http://USER:PASS@gate.example.net:8080",
"https": "http://USER:PASS@gate.example.net:8080", # http://, not https://
}
r = requests.get("https://example.com/", proxies=proxies, timeout=20)
407 Proxy Authentication Required
The tunnel got as far as the proxy and was rejected. Check the credentials, then check for an unencoded special character in the password — a raw @, : or # silently truncates the URL — with urllib.parse.quote(password, safe=""). If your provider authenticates by IP allowlist, the machine's egress IP is what matters, not your laptop's. We took that error apart separately in how to fix 407 Proxy Authentication Required.
Wrong host, wrong port, or a port that is not a proxy
Connection refused means the TCP handshake failed: nothing is listening there, or a firewall dropped it. Tunnel connection failed: 400 Bad Request means something is listening and it answered HTTP — usually a web server, a dashboard, or the provider's API port rather than the proxy port. Confirm the endpoint with the provider's docs before touching your code, and sanity-check it in a browser-free way with our proxy tester.
Environment variables you did not set
Requests reads HTTP_PROXY, HTTPS_PROXY and ALL_PROXY from the environment, so a stale export in a shell profile, a CI runner or a Docker image produces this error in code that mentions no proxy at all. Pass proxies= explicitly, or switch the whole session off with session.trust_env = False. To bypass a proxy for one call, pass proxies={"http": None, "https": None}.
SOCKS
Missing dependencies for SOCKS support. is an InvalidSchema, not a ProxyError — the scheme was rejected before any socket was opened. Install the extra with pip install "requests[socks]" and prefer socks5h:// over socks5:// so DNS is resolved at the proxy instead of leaking your resolver.
A pool that is simply exhausted
Resets and stalled CONNECTs are what a saturated or dying endpoint looks like from the client. Retry on a fresh exit rather than on the same one, which is the pattern in rotating proxies in Python; if every exit fails the same way, work through the proxy not working checklist before blaming the code.
Why the same broken proxy sometimes returns a 407 instead of raising
An HTTPS request through a proxy starts with a CONNECT that asks for a blind tunnel. If the proxy refuses, there is no tunnel and therefore no HTTP response to give you — urllib3 turns the refusal into an exception. A plain http:// request needs no tunnel: the proxy is just a forwarder, so its 407 comes back as an ordinary response and Requests hands you a Response object with status_code == 407, exactly as RFC 9110 defines it.
So the same misconfiguration is an exception on HTTPS and a silent 407 body on HTTP. Code that only wraps requests in try/except will process that 407 page as if it were data. Check r.status_code as well as catching exceptions. The browser-side twin of this failure, when the tunnel dies inside Chrome or Playwright instead of Python, is ERR_TUNNEL_CONNECTION_FAILED.
A triage snippet that names the cause
Paste this in place of a bare except and it will tell you which row of the table you are in:
import requests
from requests.exceptions import ProxyError, ConnectionError, Timeout, InvalidSchema
SIGNS = [
("only use HTTP and not HTTPS", "proxy URL scheme: use http:// in both dict keys"),
("407", "proxy credentials or IP allowlist"),
("Tunnel connection failed: 4", "that port answers HTTP but is not a proxy"),
("Connection refused", "wrong port, or the proxy is down"),
("Failed to resolve", "wrong proxy hostname"),
("Name or service not known", "wrong proxy hostname"),
]
def diagnose(url, proxies):
try:
r = requests.get(url, proxies=proxies, timeout=15)
if r.status_code == 407:
return "407 returned as a response: proxy auth failed on a plain HTTP target"
return "ok: HTTP " + str(r.status_code)
except InvalidSchema as e:
return "scheme not supported: " + str(e)
except (ProxyError, ConnectionError) as e:
text = str(e)
for needle, verdict in SIGNS:
if needle in text:
return verdict
return "connection to the proxy failed: " + text[:200]
except Timeout:
return "proxy accepted the socket but never answered - treat as a dead exit"
print(diagnose("https://example.com/", {"http": "...", "https": "..."}))
Run it against an http:// target and an https:// target. If only the HTTPS one fails, the problem is the tunnel — credentials or a proxy that does not allow CONNECT to port 443.
When the proxy is fine and the target is the problem
Once the tunnel is open, the proxy is out of the picture and blocks arrive as ordinary status codes: a 403, a 429, or a challenge page with a 200. No amount of proxy debugging fixes those, and no ProxyError will ever be raised for them. The split is worth internalising: exceptions are your side of the tunnel, status codes are theirs.
If the debugging above is a recurring tax rather than a one-off, the layer choice is the real fix. Raw exits from residential proxies start at $0.80/GB and leave you owning retries, rotation and this exact error class. Our web scraping API takes a URL and returns the page from $0.0002/page, pay per success: no proxies dict, no CONNECT, and a failed fetch is not billed. Both bill from the same $2 of free monthly credit, so you can put the two paths side by side before choosing.
Sources & further reading
- Requests source: the exceptions module
- urllib3: HTTPS proxy error / HTTP proxy
- urllib3 changelog: ProxyError now wraps any connection error (2.0.0)
- RFC 9110 section 15.5.8: 407 Proxy Authentication Required
- Stack Overflow: I am getting this error: requests.exceptions.ProxyError
- psf/requests issue 3558: Using Python's requests lib throwing ProxyError