Repository: urllib3/urllib3

Issue:
Bug: No Retry for SSL Error
That following code should fire off a `MaxRetryError`, but instead doen't retry and throws an `SSLError`.

**Test case:**
_server.py_
```python
import time
import socket

s = socket.socket()
s.bind(('', 4433))
s.listen(10)

while True:
    news, _ = s.accept()
    time.sleep(0.5)
    news.close()
```
_client.py_
```python
import urllib3
http = urllib3.PoolManager()
http.request('GET', 'https://localhost:4433/', retries=5)
```

<sup>*Disclaimer:* Test case was created by [**Lukasa**](https://github.com/kennethreitz/requests/issues/3845#issuecomment-278273421)</sup>


Discussion:
Ok, so my analysis of this bug is just that we do not retry on `SSLError`, and we probably should. The code block in `ConnectionPool.urlopen` is the one that makes this call, and I think it's just not right. I'm inclined to say that the blocks for `BaseSSLError`, `CertificateError`, and `SSLError` should all be rolled down into the block below.

Thoughts? This is something that would be good for one of the regular contributors to pick up if they want to. =)
I'm currently trying to implement this as proposed by @Lukasa, basically like this:

    diff --git a/urllib3/connectionpool.py b/urllib3/connectionpool.py
    index b4f1166..031df4c 100644
    --- a/urllib3/connectionpool.py
    +++ b/urllib3/connectionpool.py
    @@ -622,26 +622,16 @@ class HTTPConnectionPool(ConnectionPool, RequestMethods):
                 # Timed out by queue.
                 raise EmptyPoolError(self, "No pool connections are available.")

    -        except (BaseSSLError, CertificateError) as e:
    -            # Close the connection. If a connection is reused on which there
    -            # was a Certificate error, the next request will certainly raise
    -            # another Certificate error.
    -            clean_exit = False
    -            raise SSLError(e)
    -
    -        except SSLError:
    -            # Treat SSLError separately from BaseSSLError to preserve
    -            # traceback.
    -            clean_exit = False
    -            raise
    -
    -        except (TimeoutError, HTTPException, SocketError, ProtocolError) as e:
    +        except (TimeoutError, HTTPException, SocketError, ProtocolError,
    +                BaseSSLError, SSLError, CertificateError) as e:
                 # Discard the connection for these exceptions. It will be
    -            # be replaced during the next _get_conn() call.
    +            # replaced during the next _get_conn() call.
                 clean_exit = False

                 if isinstance(e, (SocketError, NewConnectionError)) and self.proxy:
                     e = ProxyError('Cannot connect to proxy.', e)
    +            elif isinstance(e, (BaseSSLError, CertificateError)):
    +                e = SSLError(e)
                 elif isinstance(e, (SocketError, HTTPException)):
                     e = ProtocolError('Connection aborted.', e)

This introduces a whole bunch of test failures, most of which are because they expect an `SSLError` but are now receiving a `MaxRetryError`. That's easy enough to fix, but it opens up another question: doesn't this break users of the library that specifically want to handle `SSLError` exceptions?
Yup, but that's ok: urllib3 does not have backward-compatibility guarantees on minor releases, so I'm not too fussed about it. :grin:
Sweet! I'll open a PR later today then!

Resolving patch:
diff --git a/urllib3/connectionpool.py b/urllib3/connectionpool.py
--- a/urllib3/connectionpool.py
+++ b/urllib3/connectionpool.py
@@ -622,25 +622,14 @@ def urlopen(self, method, url, body=None, headers=None, retries=None,
             # Timed out by queue.
             raise EmptyPoolError(self, "No pool connections are available.")
 
-        except (BaseSSLError, CertificateError) as e:
-            # Close the connection. If a connection is reused on which there
-            # was a Certificate error, the next request will certainly raise
-            # another Certificate error.
-            clean_exit = False
-            raise SSLError(e)
-
-        except SSLError:
-            # Treat SSLError separately from BaseSSLError to preserve
-            # traceback.
-            clean_exit = False
-            raise
-
-        except (TimeoutError, HTTPException, SocketError, ProtocolError) as e:
+        except (TimeoutError, HTTPException, SocketError, ProtocolError,
+                BaseSSLError, SSLError, CertificateError) as e:
             # Discard the connection for these exceptions. It will be
-            # be replaced during the next _get_conn() call.
+            # replaced during the next _get_conn() call.
             clean_exit = False
-
-            if isinstance(e, (SocketError, NewConnectionError)) and self.proxy:
+            if isinstance(e, (BaseSSLError, CertificateError)):
+                e = SSLError(e)
+            elif isinstance(e, (SocketError, NewConnectionError)) and self.proxy:
                 e = ProxyError('Cannot connect to proxy.', e)
             elif isinstance(e, (SocketError, HTTPException)):
                 e = ProtocolError('Connection aborted.', e)


Test patch:
diff --git a/test/test_connectionpool.py b/test/test_connectionpool.py
--- a/test/test_connectionpool.py
+++ b/test/test_connectionpool.py
@@ -193,18 +193,19 @@ def test_pool_size(self):
             def _raise(ex):
                 raise ex()
 
-            def _test(exception, expect):
+            def _test(exception, expect, reason=None):
                 pool._make_request = lambda *args, **kwargs: _raise(exception)
-                with pytest.raises(expect):
+                with pytest.raises(expect) as excinfo:
                     pool.request('GET', '/')
-
+                if reason is not None:
+                    assert isinstance(excinfo.value.reason, reason)
                 assert pool.pool.qsize() == POOL_SIZE
 
             # Make sure that all of the exceptions return the connection
             # to the pool
             _test(Empty, EmptyPoolError)
-            _test(BaseSSLError, SSLError)
-            _test(CertificateError, SSLError)
+            _test(BaseSSLError, MaxRetryError, SSLError)
+            _test(CertificateError, MaxRetryError, SSLError)
 
             # The pool should never be empty, and with these two exceptions
             # being raised, a retry will be triggered, but that retry will
diff --git a/test/with_dummyserver/test_https.py b/test/with_dummyserver/test_https.py
--- a/test/with_dummyserver/test_https.py
+++ b/test/with_dummyserver/test_https.py
@@ -36,6 +36,7 @@
     InsecureRequestWarning,
     SystemTimeWarning,
     InsecurePlatformWarning,
+    MaxRetryError,
 )
 from urllib3.packages import six
 from urllib3.util.timeout import Timeout
@@ -171,10 +172,11 @@ def test_invalid_common_name(self):
         try:
             https_pool.request('GET', '/')
             self.fail("Didn't raise SSL invalid common name")
-        except SSLError as e:
+        except MaxRetryError as e:
+            self.assertIsInstance(e.reason, SSLError)
             self.assertTrue(
-                "doesn't match" in str(e) or
-                "certificate verify failed" in str(e)
+                "doesn't match" in str(e.reason) or
+                "certificate verify failed" in str(e.reason)
             )
 
     def test_verified_with_bad_ca_certs(self):
@@ -186,10 +188,11 @@ def test_verified_with_bad_ca_certs(self):
         try:
             https_pool.request('GET', '/')
             self.fail("Didn't raise SSL error with bad CA certs")
-        except SSLError as e:
-            self.assertTrue('certificate verify failed' in str(e),
+        except MaxRetryError as e:
+            self.assertIsInstance(e.reason, SSLError)
+            self.assertTrue('certificate verify failed' in str(e.reason),
                             "Expected 'certificate verify failed',"
-                            "instead got: %r" % e)
+                            "instead got: %r" % e.reason)
 
     def test_verified_without_ca_certs(self):
         # default is cert_reqs=None which is ssl.CERT_NONE
@@ -201,23 +204,26 @@ def test_verified_without_ca_certs(self):
             https_pool.request('GET', '/')
             self.fail("Didn't raise SSL error with no CA certs when"
                       "CERT_REQUIRED is set")
-        except SSLError as e:
+        except MaxRetryError as e:
+            self.assertIsInstance(e.reason, SSLError)
             # there is a different error message depending on whether or
             # not pyopenssl is injected
-            self.assertTrue('No root certificates specified' in str(e) or
-                            'certificate verify failed' in str(e) or
-                            'invalid certificate chain' in str(e),
+            self.assertTrue('No root certificates specified' in str(e.reason) or
+                            'certificate verify failed' in str(e.reason) or
+                            'invalid certificate chain' in str(e.reason),
                             "Expected 'No root certificates specified',  "
                             "'certificate verify failed', or "
                             "'invalid certificate chain', "
-                            "instead got: %r" % e)
+                            "instead got: %r" % e.reason)
 
     def test_no_ssl(self):
         pool = HTTPSConnectionPool(self.host, self.port)
         pool.ConnectionCls = None
         self.addCleanup(pool.close)
         self.assertRaises(SSLError, pool._new_conn)
-        self.assertRaises(SSLError, pool.request, 'GET', '/')
+        with self.assertRaises(MaxRetryError) as cm:
+            pool.request('GET', '/', retries=0)
+        self.assertIsInstance(cm.exception.reason, SSLError)
 
     def test_unverified_ssl(self):
         """ Test that bare HTTPSConnection can connect, make requests """
@@ -324,17 +330,22 @@ def test_assert_invalid_fingerprint(self):
         https_pool.assert_fingerprint = 'AA:AA:AA:AA:AA:AAAA:AA:AAAA:AA:' \
                                         'AA:AA:AA:AA:AA:AA:AA:AA:AA'
 
-        self.assertRaises(SSLError, https_pool.request, 'GET', '/')
+        def _test_request(pool):
+            with self.assertRaises(MaxRetryError) as cm:
+                pool.request('GET', '/', retries=0)
+            self.assertIsInstance(cm.exception.reason, SSLError)
+
+        _test_request(https_pool)
         https_pool._get_conn()
 
         # Uneven length
         https_pool.assert_fingerprint = 'AA:A'
-        self.assertRaises(SSLError, https_pool.request, 'GET', '/')
+        _test_request(https_pool)
         https_pool._get_conn()
 
         # Invalid length
         https_pool.assert_fingerprint = 'AA'
-        self.assertRaises(SSLError, https_pool.request, 'GET', '/')
+        _test_request(https_pool)
 
     def test_verify_none_and_bad_fingerprint(self):
         https_pool = HTTPSConnectionPool('127.0.0.1', self.port,
@@ -344,7 +355,9 @@ def test_verify_none_and_bad_fingerprint(self):
 
         https_pool.assert_fingerprint = 'AA:AA:AA:AA:AA:AAAA:AA:AAAA:AA:' \
                                         'AA:AA:AA:AA:AA:AA:AA:AA:AA'
-        self.assertRaises(SSLError, https_pool.request, 'GET', '/')
+        with self.assertRaises(MaxRetryError) as cm:
+            https_pool.request('GET', '/', retries=0)
+        self.assertIsInstance(cm.exception.reason, SSLError)
 
     def test_verify_none_and_good_fingerprint(self):
         https_pool = HTTPSConnectionPool('127.0.0.1', self.port,
@@ -510,7 +523,9 @@ def test_discards_connection_on_sslerror(self):
         # is an issue with the OpenSSL for Python 2.6 on Windows.
 
         self._pool.cert_reqs = 'CERT_REQUIRED'
-        self.assertRaises(SSLError, self._pool.request, 'GET', '/')
+        with self.assertRaises(MaxRetryError) as cm:
+            self._pool.request('GET', '/', retries=0)
+        self.assertIsInstance(cm.exception.reason, SSLError)
         self._pool.ca_certs = DEFAULT_CA
         self._pool.request('GET', '/')
 
diff --git a/test/with_dummyserver/test_proxy_poolmanager.py b/test/with_dummyserver/test_proxy_poolmanager.py
--- a/test/with_dummyserver/test_proxy_poolmanager.py
+++ b/test/with_dummyserver/test_proxy_poolmanager.py
@@ -82,12 +82,13 @@ def test_proxy_verified(self):
         https_pool = http._new_pool('https', self.https_host,
                                     self.https_port)
         try:
-            https_pool.request('GET', '/')
+            https_pool.request('GET', '/', retries=0)
             self.fail("Didn't raise SSL error with wrong CA")
-        except SSLError as e:
-            self.assertTrue('certificate verify failed' in str(e),
+        except MaxRetryError as e:
+            self.assertIsInstance(e.reason, SSLError)
+            self.assertTrue('certificate verify failed' in str(e.reason),
                             "Expected 'certificate verify failed',"
-                            "instead got: %r" % e)
+                            "instead got: %r" % e.reason)
 
         http = proxy_from_url(self.proxy_url, cert_reqs='REQUIRED',
                               ca_certs=DEFAULT_CA)
@@ -103,10 +104,11 @@ def test_proxy_verified(self):
         https_fail_pool = http._new_pool('https', '127.0.0.1', self.https_port)
 
         try:
-            https_fail_pool.request('GET', '/')
+            https_fail_pool.request('GET', '/', retries=0)
             self.fail("Didn't raise SSL invalid common name")
-        except SSLError as e:
-            self.assertTrue("doesn't match" in str(e))
+        except MaxRetryError as e:
+            self.assertIsInstance(e.reason, SSLError)
+            self.assertTrue("doesn't match" in str(e.reason))
 
     def test_redirect(self):
         http = proxy_from_url(self.proxy_url)
diff --git a/test/with_dummyserver/test_socketlevel.py b/test/with_dummyserver/test_socketlevel.py
--- a/test/with_dummyserver/test_socketlevel.py
+++ b/test/with_dummyserver/test_socketlevel.py
@@ -79,7 +79,7 @@ def socket_handler(listener):
         self.addCleanup(pool.close)
         try:
             pool.request('GET', '/', retries=0)
-        except SSLError:  # We are violating the protocol
+        except MaxRetryError:  # We are violating the protocol
             pass
         done_receiving.wait()
         self.assertTrue(self.host.encode('ascii') in self.buf,
@@ -220,7 +220,7 @@ def socket_handler(listener):
         self.addCleanup(pool.close)
         try:
             pool.request('GET', '/', retries=0)
-        except SSLError:
+        except MaxRetryError:
             done_receiving.set()
         else:
             done_receiving.set()
@@ -942,7 +942,9 @@ def socket_handler(listener):
         pool = HTTPSConnectionPool(self.host, self.port)
         self.addCleanup(pool.close)
 
-        self.assertRaises(SSLError, pool.request, 'GET', '/', retries=0)
+        with self.assertRaises(MaxRetryError) as cm:
+            pool.request('GET', '/', retries=0)
+        self.assertIsInstance(cm.exception.reason, SSLError)
 
     def test_ssl_read_timeout(self):
         timed_out = Event()
@@ -1010,14 +1012,62 @@ def request():
                                        assert_fingerprint=fingerprint)
             try:
                 response = pool.urlopen('GET', '/', preload_content=False,
-                                        timeout=Timeout(connect=1, read=0.001))
+                                        timeout=Timeout(connect=1, read=0.001),
+                                        retries=0)
                 response.read()
             finally:
                 pool.close()
 
-        self.assertRaises(SSLError, request)
+        with self.assertRaises(MaxRetryError) as cm:
+            request()
+        self.assertIsInstance(cm.exception.reason, SSLError)
         # Should not hang, see https://github.com/shazow/urllib3/issues/529
-        self.assertRaises(SSLError, request)
+        self.assertRaises(MaxRetryError, request)
+
+    def test_retry_ssl_error(self):
+        def socket_handler(listener):
+            # first request, trigger an SSLError
+            sock = listener.accept()[0]
+            sock2 = sock.dup()
+            ssl_sock = ssl.wrap_socket(sock,
+                                       server_side=True,
+                                       keyfile=DEFAULT_CERTS['keyfile'],
+                                       certfile=DEFAULT_CERTS['certfile'])
+            buf = b''
+            while not buf.endswith(b'\r\n\r\n'):
+                buf += ssl_sock.recv(65536)
+
+            # Deliberately send from the non-SSL socket to trigger an SSLError
+            sock2.send((
+                'HTTP/1.1 200 OK\r\n'
+                'Content-Type: text/plain\r\n'
+                'Content-Length: 4\r\n'
+                '\r\n'
+                'Fail').encode('utf-8'))
+            sock2.close()
+            ssl_sock.close()
+
+            # retried request
+            sock = listener.accept()[0]
+            ssl_sock = ssl.wrap_socket(sock,
+                                       server_side=True,
+                                       keyfile=DEFAULT_CERTS['keyfile'],
+                                       certfile=DEFAULT_CERTS['certfile'])
+            buf = b''
+            while not buf.endswith(b'\r\n\r\n'):
+                buf += ssl_sock.recv(65536)
+            ssl_sock.send(b'HTTP/1.1 200 OK\r\n'
+                          b'Content-Type: text/plain\r\n'
+                          b'Content-Length: 7\r\n\r\n'
+                          b'Success')
+            ssl_sock.close()
+
+        self._start_server(socket_handler)
+
+        pool = HTTPSConnectionPool(self.host, self.port)
+        self.addCleanup(pool.close)
+        response = pool.urlopen('GET', '/', retries=1)
+        self.assertEqual(response.data, b'Success')
 
 
 class TestErrorWrapping(SocketDummyServerTestCase):