Proxy error · ProxyError

Requests ProxyError

Python Requests wraps every failure on the way through a proxy in the same long message: a ProxyError, then Max retries exceeded, then Unable to connect to proxy. None of that is the reason. The reason is the last thing inside the innermost brackets, and it tells you which of a handful of problems you have.
Whose fault is it?

Read the inner cause before deciding. Most of the time it is the proxy URL or the credentials in it; a 502 inside points at the path to the site.

Usually from
Your proxy URL; the inner cause says which part
Try first
Read the innermost exception in the message
With a proxy in the path

What a ProxyError means through a proxy

A typical message reads ProxyError: HTTPSConnectionPool(host='example.com', port=443): Max retries exceeded with url: / (Caused by ProxyError('Unable to connect to proxy', OSError('Tunnel connection failed: 407 Proxy Authentication Required'))). Max retries exceeded shows up even when nothing was retried: it is how urllib3 words its final error, and Requests does not retry by default.

The host in HTTPSConnectionPool(...) is the site you asked for, not the proxy, because for an https:// URL urllib3 keeps a pool per site and tunnels through the proxy. For an http:// URL the message says HTTPConnectionPool and names the proxy itself.

The inner cause is what matters. Connection refused or Failed to resolve means Python never reached the proxy. Tunnel connection failed: 407 Proxy Authentication Required means the proxy rejected the login. Tunnel connection failed: 502 means the proxy could not reach the site. WRONG_VERSION_NUMBER means the proxy URL starts with https://.

The key question

Your credentials, the proxy, or the target?

Read the inner cause before deciding. Most of the time it is the proxy URL or the credentials in it; a 502 inside points at the path to the site.

  • Your credentials

    When the inner cause says 407. Usually a special character in the password that broke the URL, or a newline read in with it.

  • The proxy

    When it says Connection refused or fails to resolve the proxy host: the address in your config. When it says 502 or 503, the gateway could not reach the site through the exit it picked.

  • The target site

    Only behind a 502 or 503 in the inner cause. A site that answers at all gives you a Response, not a ProxyError.

How to tell

  • Search the message for Caused by. Everything before it is packaging.
  • urllib3 1.26 says Cannot connect to proxy. where urllib3 2 says Unable to connect to proxy. Same meaning, older library.
  • err.args[0].reason.original_error is the inner exception as an object, so code can branch on it instead of parsing the text.
  • A password that works with curl --proxy-user but fails inside the proxy URL has a character that needs encoding.
Cheapest first

Fixes, in the order to try them

  1. Read the innermost cause

    Go to the last Caused by and read the exception inside it. Connection refused, 407, 502 and WRONG_VERSION_NUMBER each point somewhere different, and the fixes below follow them.

    Costs nothing
  2. Percent-encode the password

    A password containing @, :, /, # or % breaks http://user:pass@host:port. Build the URL with urllib.parse.quote(password, safe=""), which turns @ into %40. Requests decodes it again before sending.

    Costs nothing
  3. Fix the scheme and the address

    The proxy URL starts with http:// under both the http and https keys, and host and port match the dashboard exactly. Print the URL with the password masked to check.

    Costs nothing
  4. Stop the environment adding a proxy

    Requests fills any key you did not set from HTTP_PROXY and HTTPS_PROXY, and on Windows and macOS from the system settings. Set trust_env = False on a Session to use only the proxies you pass.

    Costs nothing
  5. Retry what deserves a retry

    A 502 or 503 inside the error is often one bad exit, and a second attempt on the rotating gateway leaves from another. Retry those once or twice. A 407 or Connection refused fails the same way every time, so fix it instead.

    Costs some time
Per tool

See the real status and headers

Each sample digs out the inner cause, or its equivalent in that tool, which is the only part of the message worth reading.

curl
terminal
# the same credentials outside Python: if this works, the URL in your code is the problem
curl -s -o /dev/null -w 'CONNECT %{http_connect}  site %{http_code}\n' \
     -x http://resi.proxymonkey.io:8000 --proxy-user 'USER:PASS' https://example.com/

# the encoded form of a password, ready for a proxy URL
python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' 'PASS'
Python Requests
inner_cause.py
import os
from urllib.parse import quote

import requests

user = os.environ["PM_USER"].strip()
password = quote(os.environ["PM_PASS"].strip(), safe="")
PROXY = f"http://{user}:{password}@resi.proxymonkey.io:8000"

try:
    r = requests.get("https://example.com/", proxies={"http": PROXY, "https": PROXY}, timeout=30)
    print("through the proxy:", r.status_code)
except requests.exceptions.ProxyError as err:
    reason = err.args[0].reason
    inner = getattr(reason, "original_error", reason)
    print(type(inner).__name__, inner)

# NewConnectionError ... Failed to establish a new connection: [Errno 111] Connection refused
# OSError Tunnel connection failed: 407 Proxy Authentication Required
# OSError Tunnel connection failed: 502 Bad Gateway
Scrapy
in the spider
from scrapy.core.downloader.handlers.http11 import TunnelError
from scrapy.exceptions import (
    CannotResolveHostError,
    DownloadConnectionRefusedError,
    DownloadFailedError,
    DownloadTimeoutError,
)

CAUSES = {
    DownloadConnectionRefusedError: "refused at the proxy address",
    CannotResolveHostError: "a hostname did not resolve",
    TunnelError: "the proxy answered CONNECT with an error",
    DownloadTimeoutError: "no answer in time",
    DownloadFailedError: "the connection failed after it opened",
}

async def start(self):
    for url in self.start_urls:
        yield scrapy.Request(url, errback=self.on_error)

def on_error(self, failure):
    for kind, meaning in CAUSES.items():
        if failure.check(kind):
            self.logger.error("%s: %s", meaning, failure.value)
            return
    self.logger.error("%r", failure.value)

Scrapy has no ProxyError. The same causes arrive as these exceptions, and a TunnelError carries the status the proxy sent.

Playwright
inner_cause.py
from playwright.sync_api import Error, sync_playwright

PROXY = {"server": "http://resi.proxymonkey.io:8000", "username": "USER", "password": "PASS"}

CAUSES = {
    "ERR_PROXY_CONNECTION_FAILED": "never reached the proxy",
    "ERR_TUNNEL_CONNECTION_FAILED": "the proxy refused CONNECT",
    "ERR_TIMED_OUT": "no answer in time",
}

with sync_playwright() as p:
    browser = p.chromium.launch(proxy=PROXY)
    page = browser.new_page()
    try:
        page.goto("https://example.com/")
    except Error as err:
        cause = next((c for c in CAUSES if c in err.message), None)
        print(CAUSES.get(cause, err.message.splitlines()[0]))
    browser.close()

Playwright takes the password in its own password field, so it needs no URL encoding.

Node.js
inner-cause.mjs
import { fetch, ProxyAgent } from "undici";

const user = encodeURIComponent(process.env.PM_USER.trim());
const pass = encodeURIComponent(process.env.PM_PASS.trim());
const dispatcher = new ProxyAgent("http://" + user + ":" + pass + "@resi.proxymonkey.io:8000");

try {
  await fetch("https://example.com/", { dispatcher });
} catch (err) {
  const chain = [];
  for (let e = err; e; e = e.cause) chain.push(e.code ?? e.message);
  console.error(chain.join(" <- "));
}

// fetch failed <- ECONNREFUSED

undici decodes the username and password in the proxy URL before sending them, so encoding them here is safe.

The samples use the residential gateway, resi.proxymonkey.io:8000. For an ISP or datacenter IP, use USER:PASS@IP:PORT for the address you rented. Your dashboard lists the host and port for every order, and where it differs from this page, the dashboard is right.

Before you buy anything

Will a different proxy line fix it?

When switching helps

Only when the inner cause is a scattered 502 from residential exits on one target. A static ISP or datacenter IP gives a steadier path, if the target accepts that kind of address.

When it will not

A refused connection, a 407 or an https:// proxy URL is in your config, and follows you to every line.

The meter

Is a failed request billed?

We bill for request bytes and response bytes, including headers and protocol overhead on the tunnelled connection. Connections that fail before transferring data are not billed. Retries that you initiate are billed and appear as separate entries in your usage log.

From the metering section of our terms of service.

The terms say connections that fail before transferring data are not billed. A refused connection or a failed handshake with the proxy moves nothing through it. For a tunnel the proxy refused, check the row in your usage log and ask in Discord if it looks wrong. Retries you initiate are billed as separate entries.

Residential is billed per GB of that traffic. ISP and datacenter addresses are charged per IP for their term, and where a plan includes a traffic allowance, traffic past it is billed per GB under the same rule. The usage log in your dashboard has one row per request with bytes in, bytes out and cost, so you can look up the failed request yourself.

ProxyError, asked often

Questions people ask about a ProxyError

Why does it say Max retries exceeded when I never retried?

That is how urllib3 words the final error, whether or not any retries happened. Requests does not retry by default, so it usually means one attempt failed. The real reason is further along the message, after Caused by.

Why does the error name the website and not my proxy?

For an https URL, urllib3 keeps a connection pool per site and tunnels to it through the proxy, so the message names the site. The inner cause tells you whether the failure was on the way to the proxy or at the proxy.

My password has special characters. What do I do?

Encode it with urllib.parse.quote and safe set to an empty string before putting it in the proxy URL. An @ becomes %40 and a colon %3A. Requests decodes it again before sending, so the proxy sees your real password.

Is Unable to connect to proxy the proxy being down?

Not necessarily. urllib3 uses those words for every failure before a working tunnel exists, including a 407 for a wrong password and a 502 when the proxy could not reach the site. Read the exception inside it.

The community layer

Still stuck on a ProxyError?

Paste your error in the Discord: the full message plus the command or the few lines that set up the proxy, with the password taken out. Someone there has seen it before.

Join the Discord

4,200+monkeys in the Discord

  • Help from humans

    Post your error, get an answer. Usually in minutes, usually from someone who has hit the same wall.

  • A status bot that tells on us

    Pool health, incidents and maintenance posted automatically. Including the bad days.

  • Deals and free traffic

    Bonus GB drops, early access to new pools, and the occasional giveaway for a good bug report.

Join the Discord4,200+ monkeys, free to lurk