Don't unconditionally wrap proxy connection errors with HTTPSProxyError
MODIFY
docs/advanced-usage.rst
docs/advanced-usage.rst
@@ -210,6 +210,78 @@ an `absolute URI <https://tools.ietf.org/html/rfc7230#section-5.3.2>`_ if the
**only use this option with trusted or corporate proxies** as the proxy will have
full visibility of your requests.
+.. _https_proxy_error_http_proxy:
+
+How to fix HTTPSProxyError?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you're receiving the :class:`~urllib3.exceptions.HTTPSProxyError` and it mentions
+your proxy only speaks HTTP and not HTTPS here's what to do to solve your issue:
+
+If you're using ``urllib3`` directly, make sure the URL you're passing into :class:`urllib3.ProxyManager`
+starts with ``http://`` instead of ``https://``:
+
+.. code-block:: python
+
+ # Do this:
+ http = urllib3.ProxyManager("http://...")
+
+ # Not this:
+ http = urllib3.ProxyManager("https://...")
+
+If instead you're using ``urllib3`` through another library like Requests
+there are multiple ways your proxy could be mis-configured. You need to figure out
+where the configuration isn't correct and make the fix there. Some common places
+to look are environment variables like ``HTTP_PROXY``, ``HTTPS_PROXY``, and ``ALL_PROXY``.
+
+Ensure that the values for all of these environment variables starts with ``http://``
+and not ``https://``:
+
+.. code-block:: bash
+
+ # Check your existing environment variables in bash
+ $ env | grep "_PROXY"
+ HTTP_PROXY=http://127.0.0.1:8888
+ HTTPS_PROXY=https://127.0.0.1:8888 # <--- This setting is the problem!
+
+ # Make the fix in your current session and test your script
+ $ export HTTPS_PROXY="http://127.0.0.1:8888"
+ $ python test-proxy.py # This should now pass.
+
+ # Persist your change in your shell 'profile' (~/.bashrc, ~/.profile, ~/.bash_profile, etc)
+ # You may need to logout and log back in to ensure this works across all programs.
+ $ vim ~/.bashrc
+
+If you're on Windows or macOS your proxy may be getting set at a system level.
+To check this first ensure that the above environment variables aren't set
+then run the following:
+
+.. code-block:: bash
+
+ $ python -c 'import urllib.request; print(urllib.request.getproxies())'
+
+If the output of the above command isn't empty and looks like this:
+
+.. code-block:: python
+
+ {
+ "http": "http://127.0.0.1:8888",
+ "https": "https://127.0.0.1:8888" # <--- This setting is the problem!
+ }
+
+Search how to configure proxies on your operating system and change the ``https://...`` URL into ``http://``.
+After you make the change the return value of ``urllib.request.getproxies()`` should be:
+
+.. code-block:: python
+
+ { # Everything is good here! :)
+ "http": "http://127.0.0.1:8888",
+ "https": "http://127.0.0.1:8888"
+ }
+
+If you still can't figure out how to configure your proxy after all these steps
+please `join our community Discord <https://discord.gg/urllib3>`_ and we'll try to help you with your issue.
+
SOCKS Proxies
~~~~~~~~~~~~~

MODIFY
src/urllib3/connection.py
src/urllib3/connection.py
@@ -23,7 +23,7 @@ from typing import (
)
if TYPE_CHECKING:
- from typing_extensions import Literal
+ from typing_extensions import Literal, NoReturn
from .util.proxy import create_proxy_ssl_context
from .util.util import to_bytes, to_str
@@ -533,9 +533,7 @@ class HTTPSConnection(HTTPConnection):
self.assert_fingerprint
)
- def _connect_tls_proxy(
- self, hostname: Optional[str], conn: socket.socket
- ) -> "ssl.SSLSocket":
+ def _connect_tls_proxy(self, hostname: str, conn: socket.socket) -> "ssl.SSLSocket":
"""
Establish a TLS connection to the proxy using the provided SSL context.
"""
@@ -574,12 +572,9 @@ class HTTPSConnection(HTTPConnection):
ssl_context=ssl_context,
)
except Exception as e:
- # Wrap into an HTTPSProxyError for easier diagnosis.
- # Original exception is available on original_error and
- # as __cause__.
- raise HTTPSProxyError(
- f"Unable to establish a TLS connection to {hostname}", e
- ) from e
+ if not _should_wrap_https_proxy_error(e):
+ raise
+ _wrap_https_proxy_error(e, hostname)
def _match_hostname(cert: _TYPE_PEER_CERT_RET, asserted_hostname: str) -> None:
@@ -597,6 +592,32 @@ def _match_hostname(cert: _TYPE_PEER_CERT_RET, asserted_hostname: str) -> None:
raise
+def _should_wrap_https_proxy_error(err: Exception) -> bool:
+ """Detects if the error should be wrapped into :class:`urllib3.exceptions.HTTPSProxyError`"""
+ return isinstance(err, (CertificateError, BaseSSLError, ssl_.SSLError)) # type: ignore[attr-defined]
+
+
+def _wrap_https_proxy_error(err: Exception, hostname: str) -> "NoReturn":
+ # Look for the phrase 'wrong version number', if found
+ # then we should warn the user that we're very sure that
+ # this proxy is HTTP-only and they have a configuration issue.
+ error_normalized = " ".join(re.split("[^a-z]", str(err).lower()))
+ is_likely_http_proxy = "wrong version number" in error_normalized
+ http_proxy_warning = (
+ ". Your proxy appears to only use HTTP and not HTTPS, "
+ "did you intend to set a proxy URL using HTTPS instead of HTTP?"
+ )
+
+ # Wrap into an HTTPSProxyError for easier diagnosis.
+ # Original exception is available on original_error and
+ # as __cause__.
+ raise HTTPSProxyError(
+ f"Unable to establish a TLS connection to {hostname}"
+ f"{http_proxy_warning if is_likely_http_proxy else ''}",
+ err,
+ ) from err
+
+
def _get_default_user_agent() -> str:
return f"python-urllib3/{__version__}"

MODIFY
src/urllib3/connectionpool.py
src/urllib3/connectionpool.py
@@ -7,7 +7,17 @@ import warnings
from http.client import HTTPResponse as _HttplibHTTPResponse
from socket import timeout as SocketTimeout
from types import TracebackType
-from typing import TYPE_CHECKING, Any, Mapping, Optional, Type, TypeVar, Union, overload
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Mapping,
+ Optional,
+ Type,
+ TypeVar,
+ Union,
+ cast,
+ overload,
+)
from ._request_methods import RequestMethods
from .connection import (
@@ -20,6 +30,8 @@ from .connection import (
HTTPSConnection,
ProxyConfig,
VerifiedHTTPSConnection,
+ _should_wrap_https_proxy_error,
+ _wrap_https_proxy_error,
)
from .connection import port_by_scheme as port_by_scheme
from .exceptions import (
@@ -1017,8 +1029,18 @@ class HTTPSConnectionPool(HTTPConnectionPool):
super()._validate_conn(conn)
# Force connect early to allow us to validate the connection.
+ try:
if not conn.sock:
conn.connect()
+ except Exception as e:
+ # We only want to wrap errors for HTTPS proxies
+ if (
+ not self.proxy
+ or self.proxy.scheme != "https"
+ or not _should_wrap_https_proxy_error(e)
+ ):
+ raise
+ _wrap_https_proxy_error(e, cast(str, self.proxy.host))
if not conn.is_verified:
warnings.warn(

MODIFY
test/with_dummyserver/test_proxy_poolmanager.py
test/with_dummyserver/test_proxy_poolmanager.py
@@ -7,6 +7,7 @@ from test import LONG_TIMEOUT, SHORT_TIMEOUT, onlySecureTransport, withPyOpenSSL
import pytest
import trustme
+import urllib3.exceptions
from dummyserver.server import DEFAULT_CA, HAS_IPV6, get_unreachable_address
from dummyserver.testcase import HTTPDummyProxyTestCase, IPv6HTTPDummyProxyTestCase
from urllib3 import HTTPResponse
@@ -15,11 +16,11 @@ from urllib3.connection import VerifiedHTTPSConnection
from urllib3.connectionpool import connection_from_url
from urllib3.exceptions import (
ConnectTimeoutError,
- HTTPSProxyError,
MaxRetryError,
ProxyError,
ProxySchemeUnknown,
ProxySchemeUnsupported,
+ ReadTimeoutError,
SSLError,
)
from urllib3.poolmanager import ProxyManager, proxy_from_url
@@ -135,32 +136,43 @@ class TestHTTPProxyManager(HTTPDummyProxyTestCase):
finally:
conn.close()
- def test_proxy_conn_fail(self) -> None:
+ @pytest.mark.parametrize("proxy_scheme", ["http", "https"])
+ @pytest.mark.parametrize("target_scheme", ["http", "https"])
+ def test_proxy_conn_fail_from_dns(
+ self, proxy_scheme: str, target_scheme: str
+ ) -> None:
host, port = get_unreachable_address()
with proxy_from_url(
- f"http://{host}:{port}/", retries=1, timeout=LONG_TIMEOUT
+ f"{proxy_scheme}://{host}:{port}/", retries=1, timeout=LONG_TIMEOUT
) as http:
- with pytest.raises(MaxRetryError):
- http.request("GET", f"{self.https_url}/")
- with pytest.raises(MaxRetryError):
- http.request("GET", f"{self.http_url}/")
+ if target_scheme == "https":
+ target_url = self.https_url
+ else:
+ target_url = self.http_url
with pytest.raises(MaxRetryError) as e:
- http.request("GET", f"{self.http_url}/")
+ http.request("GET", f"{target_url}/")
assert type(e.value.reason) == ProxyError
+ assert (
+ type(e.value.reason.original_error)
+ == urllib3.exceptions.NameResolutionError
+ )
- def test_https_conn_failed(self) -> None:
- """
- Simulates a misconfiguration that is common for users. The test attempts
- to establish a TLS connection to a non-TLS proxy
- """
+ @pytest.mark.parametrize("target_scheme", ["http", "https"])
+ def test_https_proxy_error_doesnt_wrap_timeouts(self, target_scheme: str) -> None:
bad_proxy_url = f"https://{self.proxy_host}:{int(self.proxy_port)}"
with proxy_from_url(
bad_proxy_url, ca_certs=DEFAULT_CA, timeout=LONG_TIMEOUT
) as http:
with pytest.raises(MaxRetryError) as e:
- http.request("GET", f"{self.https_url}/")
- assert type(e.value.reason) == HTTPSProxyError
+ http.request(
+ "GET", f"{target_scheme}://{self.http_host}:{self.http_port}"
+ )
+
+ if type(e.value.reason) == ProxyError:
+ assert type(e.value.reason.original_error) == socket.timeout
+ else:
+ assert type(e.value.reason) == ReadTimeoutError
def test_oldapi(self) -> None:
with ProxyManager(

MODIFY
test/with_dummyserver/test_socketlevel.py
test/with_dummyserver/test_socketlevel.py
@@ -30,10 +30,11 @@ from dummyserver.server import (
get_unreachable_address,
)
from dummyserver.testcase import SocketDummyServerTestCase, consume_socket
-from urllib3 import HTTPConnectionPool, HTTPSConnectionPool, util
+from urllib3 import HTTPConnectionPool, HTTPSConnectionPool, ProxyManager, util
from urllib3._collections import HTTPHeaderDict
from urllib3.connection import HTTPConnection, _get_default_user_agent
from urllib3.exceptions import (
+ HTTPSProxyError,
MaxRetryError,
ProtocolError,
ProxyError,
@@ -1145,6 +1146,33 @@ class TestProxyManager(SocketDummyServerTestCase):
except MaxRetryError:
self.fail("Invalid IPv6 format in HTTP CONNECT request")
+ @pytest.mark.parametrize("target_scheme", ["http", "https"])
+ def test_https_proxymanager_connected_to_http_proxy(
+ self, target_scheme: str
+ ) -> None:
+
+ errored = Event()
+
+ def http_socket_handler(listener):
+ sock = listener.accept()[0]
+ sock.send(b"HTTP/1.0 501 Not Implemented\r\nConnection: close\r\n\r\n")
+ errored.wait()
+ sock.close()
+
+ self._start_server(http_socket_handler)
+ base_url = f"https://{self.host}:{self.port}"
+
+ with ProxyManager(base_url, cert_reqs="NONE") as proxy:
+ with pytest.raises(MaxRetryError) as e:
+ proxy.request("GET", f"{target_scheme}://example.com", retries=0)
+
+ errored.set() # Avoid a ConnectionAbortedError on Windows.
+
+ assert type(e.value.reason) == HTTPSProxyError
+ assert "Your proxy appears to only use HTTP and not HTTPS" in str(
+ e.value.reason
+ )
+
class TestSSL(SocketDummyServerTestCase):
def test_ssl_failure_midway_through_conn(self):

Don't unconditionally wrap proxy connection errors with HTTPSProxyError