Repository: urllib3/urllib3

Issue:
PoolManager and ProxyManager process retries differently
Urllib version: 1.25.6

I want to do zero retries and fail on first error. I use `Retry(total=None, connect=False)` which must fail on first error according to documentaion.

Using PoolManager I get NewConnectionError as expected:
```python
from urllib3 import PoolManager
from urllib3.util.retry import Retry


mgr = PoolManager()
mgr.urlopen(
    'GET',
    url='http://localhost:90/',
    retries=Retry(total=None, connect=False),
)
```
Error:
```
Traceback (most recent call last):
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connection.py", line 157, in _new_conn
    (self._dns_host, self.port), self.timeout, **extra_kw
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/util/connection.py", line 84, in create_connection
    raise err
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/util/connection.py", line 74, in create_connection
    sock.connect(sa)
ConnectionRefusedError: [Errno 111] Connection refused

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "test.py", line 9, in <module>
    retries=Retry(total=None, connect=False),
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/poolmanager.py", line 330, in urlopen
    response = conn.urlopen(method, u.request_uri, **kw)
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 720, in urlopen
    method, url, error=e, _pool=self, _stacktrace=sys.exc_info()[2]
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/util/retry.py", line 393, in increment
    raise six.reraise(type(error), error, _stacktrace)
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/packages/six.py", line 735, in reraise
    raise value
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 672, in urlopen
    chunked=chunked,
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 387, in _make_request
    conn.request(method, url, **httplib_request_kw)
  File "/usr/lib/python3.5/http/client.py", line 1107, in request
    self._send_request(method, url, body, headers)
  File "/usr/lib/python3.5/http/client.py", line 1152, in _send_request
    self.endheaders(body)
  File "/usr/lib/python3.5/http/client.py", line 1103, in endheaders
    self._send_output(message_body)
  File "/usr/lib/python3.5/http/client.py", line 934, in _send_output
    self.send(msg)
  File "/usr/lib/python3.5/http/client.py", line 877, in send
    self.connect()
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connection.py", line 184, in connect
    conn = self._new_conn()
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connection.py", line 169, in _new_conn
    self, "Failed to establish a new connection: %s" % e
urllib3.exceptions.NewConnectionError: <urllib3.connection.HTTPConnection object at 0x7f41c4e512e8>: Failed to establish a new connection: [Errno 111] Connection refused
```

Using ProxyManager I get recursion error:
```python
from urllib3 import ProxyManager
from urllib3.util.retry import Retry

mgr = ProxyManager('http://localhost:91')
mgr.urlopen(
    'HEAD',
    url='http://localhost:90/',
    retries=Retry(total=None, connect=False),
)
```

Error:
```
Traceback (most recent call last):
  File "test.py", line 20, in <module>
    retries=Retry(total=None, connect=False),
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/poolmanager.py", line 466, in urlopen
    return super(ProxyManager, self).urlopen(method, url, redirect=redirect, **kw)
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/poolmanager.py", line 328, in urlopen
    response = conn.urlopen(method, url, **kw)

.......... LOT OF SAME LINES ...........

  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 762, in urlopen
    **response_kw
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 762, in urlopen
    **response_kw
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 762, in urlopen
    **response_kw
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 762, in urlopen
    **response_kw
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 672, in urlopen
    chunked=chunked,
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connectionpool.py", line 387, in _make_request
    conn.request(method, url, **httplib_request_kw)
  File "/usr/lib/python3.5/http/client.py", line 1107, in request
    self._send_request(method, url, body, headers)
  File "/usr/lib/python3.5/http/client.py", line 1152, in _send_request
    self.endheaders(body)
  File "/usr/lib/python3.5/http/client.py", line 1103, in endheaders
    self._send_output(message_body)
  File "/usr/lib/python3.5/http/client.py", line 934, in _send_output
    self.send(msg)
  File "/usr/lib/python3.5/http/client.py", line 877, in send
    self.connect()
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connection.py", line 184, in connect
    conn = self._new_conn()
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/connection.py", line 157, in _new_conn
    (self._dns_host, self.port), self.timeout, **extra_kw
  File "/tmp/z5/lib/python3.5/site-packages/urllib3/util/connection.py", line 61, in create_connection
    for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM):
  File "/usr/lib/python3.5/socket.py", line 735, in getaddrinfo
    addrlist.append((_intenum_converter(af, AddressFamily),
  File "/usr/lib/python3.5/socket.py", line 93, in _intenum_converter
    return enum_klass(value)
  File "/usr/lib/python3.5/enum.py", line 241, in __call__
    return cls.__new__(cls, value)
  File "/usr/lib/python3.5/enum.py", line 463, in __new__
    if type(value) is cls:
RecursionError: maximum recursion depth exceeded while calling a Python object
```


Discussion:
Huh, definitely seems like a bug. Will require some more digging!
Maybe it is related to https://github.com/urllib3/urllib3/issues/1746? I have no idea, just linking my issues together :D
No, it's not related :) What's happening it that the NewConnectionError error is wrapped in a `ProxyError` when we're connecting to a proxy:

https://github.com/urllib3/urllib3/blob/37ba61a8b8120cbd866d057eaa3936f4b140dee0/src/urllib3/connectionpool.py#L714-L715

But the retries code expects to see a `ConnectTimeoutError`:

https://github.com/urllib3/urllib3/blob/37ba61a8b8120cbd866d057eaa3936f4b140dee0/src/urllib3/util/retry.py#L305-L309

And indeed `NewConnectionError` is a subclass of `ConnectTimeoutError`:

https://github.com/urllib3/urllib3/blob/37ba61a8b8120cbd866d057eaa3936f4b140dee0/src/urllib3/exceptions.py#L123-L125

----

The fix that comes to mind is to modify the `_is_connection_error` and `_is_read_error` functions to peek inside the `ProxyError` exception. I don't think such a fix would cause more problems?
@pquentin Yep, I think that's a suitable solution! :)
@lorien Is this something that you would like to work on?
@pquentin Yeah, I can do it.

Resolving patch:
diff --git a/src/urllib3/exceptions.py b/src/urllib3/exceptions.py
--- a/src/urllib3/exceptions.py
+++ b/src/urllib3/exceptions.py
@@ -45,7 +45,10 @@ class SSLError(HTTPError):
 
 class ProxyError(HTTPError):
     "Raised when the connection to a proxy fails."
-    pass
+
+    def __init__(self, message, error, *args):
+        super(ProxyError, self).__init__(message, error, *args)
+        self.original_error = error
 
 
 class DecodeError(HTTPError):
diff --git a/src/urllib3/util/retry.py b/src/urllib3/util/retry.py
--- a/src/urllib3/util/retry.py
+++ b/src/urllib3/util/retry.py
@@ -13,6 +13,7 @@
     ReadTimeoutError,
     ResponseError,
     InvalidHeader,
+    ProxyError,
 )
 from ..packages import six
 
@@ -306,6 +307,8 @@ def _is_connection_error(self, err):
         """ Errors when we're fairly sure that the server did not receive the
         request, so it should be safe to retry.
         """
+        if isinstance(err, ProxyError):
+            err = err.original_error
         return isinstance(err, ConnectTimeoutError)
 
     def _is_read_error(self, err):


Test patch:
diff --git a/test/test_proxymanager.py b/test/test_proxymanager.py
--- a/test/test_proxymanager.py
+++ b/test/test_proxymanager.py
@@ -1,7 +1,14 @@
 import pytest
 
+from .port_helpers import find_unused_port
 from urllib3.poolmanager import ProxyManager
 from urllib3.util.url import parse_url
+from urllib3.util.retry import Retry
+from urllib3.exceptions import (
+    MaxRetryError,
+    ProxyError,
+    NewConnectionError,
+)
 
 
 class TestProxyManager(object):
@@ -57,3 +64,17 @@ def test_proxy_tunnel(self):
         with ProxyManager("https://proxy:8080") as p:
             assert p._proxy_requires_url_absolute_form(http_url)
             assert p._proxy_requires_url_absolute_form(https_url)
+
+    def test_proxy_connect_retry(self):
+        retry = Retry(total=None, connect=False)
+        with find_unused_port() as port:
+            with ProxyManager("http://localhost:{}".format(port)) as p:
+                with pytest.raises(ProxyError) as ei:
+                    p.urlopen("HEAD", url="http://localhost/", retries=retry)
+                assert isinstance(ei.value.original_error, NewConnectionError)
+
+        retry = Retry(total=None, connect=2)
+        with ProxyManager("http://localhost:{}".format(port)) as p:
+            with pytest.raises(MaxRetryError) as ei:
+                p.urlopen("HEAD", url="http://localhost/", retries=retry)
+            assert isinstance(ei.value.reason.original_error, NewConnectionError)