Repository: urllib3/urllib3

Issue:
timeout can be raised during _validate_conn
Steps to reproduce:
- set up a port to accept incoming TCP traffic, and never send any data back (for example port 5501 on [hamms](https://github.com/kevinburke/hamms))
- make an HTTPS request to the port with a timeout

``` python
import urllib3, certifi
http = urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where())
r = http.request('GET', 'https://localhost:5501/', timeout=3)
```

The traceback is roughly this:

``` python
  File "urllib3/request.py", line 68, in request
    **urlopen_kw)
  File "urllib3/request.py", line 81, in request_encode_url
    return self.urlopen(method, url, **urlopen_kw)
  File "urllib3/poolmanager.py", line 153, in urlopen
    response = conn.urlopen(method, u.request_uri, **kw)
  File "urllib3/connectionpool.py", line 516, in urlopen
    body=body, headers=headers)
  File "urllib3/connectionpool.py", line 304, in _make_request
    self._validate_conn(conn)
  File "urllib3/connectionpool.py", line 722, in _validate_conn
    conn.connect()
  File "urllib3/connection.py", line 237, in connect
    ssl_version=resolved_ssl_version)
  File "urllib3/util/ssl_.py", line 132, in ssl_wrap_socket
    ssl_version=ssl_version)
  File "/usr/local/Cellar/python/2.7.8_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/ssl.py", line 392, in wrap_socket
    ciphers=ciphers)
  File "/usr/local/Cellar/python/2.7.8_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/ssl.py", line 148, in __init__
    self.do_handshake()
  File "/usr/local/Cellar/python/2.7.8_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/ssl.py", line 310, in do_handshake
    self._sslobj.do_handshake()
```

The error is a SSLError but it seems like this should be a timeout:

> SSLError: [Errno 0] The handshake operation timed out

We have logic to catch SSLErrors and raise them as timeouts, which would catch this:

```
    except BaseSSLError as e:
        # Catch possible read timeouts thrown as SSL errors. If not the
        # case, rethrow the original. We need to do this because of:
        # http://bugs.python.org/issue10272
        if 'timed out' in str(e) or \
           'did not complete (read)' in str(e):  # Python 2.6
            raise ReadTimeoutError(
                    self, url, "Read timed out. (read timeout=%s)" % read_timeout)

        raise
```

Unfortunately this code block doesn't catch this timeout which is raised during `_validate_conn`.

There are a few options here.
- duplicate the `if "timed out" in ...` logic around `_validate_conn`
- move the `if "timed out" in ...` logic up to urlopen. Existing read timeout errors would get caught in the `except (BaseSSLError, CertificateError) as e` block in urlopen, and not have the connection closed/replaced.
- do nothing and leave "handshake timed out" errors as SSLErrors.



Discussion:
That's super-thorough, thanks Kevin!

> set up a port to accept incoming TCP traffic, and never send any data back

On the bright side, would be trivial to write a socket-level test for this. :D

> There are a few options here. [...]

Maybe we should pull out the "re-raise as TimeoutError" sub-blocks in the giant try/except into somekind of `raise_timeout(exception)` helper which does the checking and re-raising that we can re-use in/around `_validate_conn()`.

Then,

``` python
        except SocketTimeout:
            ...
        except BaseSSLError as e:
            ...
            raise
        except SocketError as e:
            ...
            raise
```

Will become,

``` python
        except (SocketTimeout, SocketError, BaseSSLError) as e:
            raise_timeout(e)
            raise
```

Thoughts?


Resolving patch:
diff --git a/urllib3/connectionpool.py b/urllib3/connectionpool.py
--- a/urllib3/connectionpool.py
+++ b/urllib3/connectionpool.py
@@ -278,6 +278,23 @@ def _get_timeout(self, timeout):
             # can be removed later
             return Timeout.from_float(timeout)
 
+    def _raise_timeout(self, err, url, timeout_value):
+        """Is the error actually a timeout? Will raise a ReadTimeout or pass"""
+
+        if isinstance(err, SocketTimeout):
+            raise ReadTimeoutError(self, url, "Read timed out. (read timeout=%s)" % timeout_value)
+
+        # See the above comment about EAGAIN in Python 3. In Python 2 we have
+        # to specifically catch it and throw the timeout error
+        if hasattr(err, 'errno') and err.errno in _blocking_errnos:
+            raise ReadTimeoutError(self, url, "Read timed out. (read timeout=%s)" % timeout_value)
+
+        # Catch possible read timeouts thrown as SSL errors. If not the
+        # case, rethrow the original. We need to do this because of:
+        # http://bugs.python.org/issue10272
+        if 'timed out' in str(err) or 'did not complete (read)' in str(err):  # Python 2.6
+            raise ReadTimeoutError(self, url, "Read timed out. (read timeout=%s)" % timeout_value)
+
     def _make_request(self, conn, method, url, timeout=_Default,
                       **httplib_request_kw):
         """
@@ -301,7 +318,12 @@ def _make_request(self, conn, method, url, timeout=_Default,
         conn.timeout = timeout_obj.connect_timeout
 
         # Trigger any extra validation we need to do.
-        self._validate_conn(conn)
+        try:
+            self._validate_conn(conn)
+        except (SocketTimeout, BaseSSLError) as e:
+            # Py2 raises this as a BaseSSLError, Py3 raises it as socket timeout.
+            self._raise_timeout(err=e, url=url, timeout_value=conn.timeout)
+            raise
 
         # conn.request() calls httplib.*.request, not the method in
         # urllib3.request. It also calls makefile (recv) on the socket.
@@ -331,28 +353,8 @@ def _make_request(self, conn, method, url, timeout=_Default,
                 httplib_response = conn.getresponse(buffering=True)
             except TypeError:  # Python 2.6 and older
                 httplib_response = conn.getresponse()
-        except SocketTimeout:
-            raise ReadTimeoutError(
-                self, url, "Read timed out. (read timeout=%s)" % read_timeout)
-
-        except BaseSSLError as e:
-            # Catch possible read timeouts thrown as SSL errors. If not the
-            # case, rethrow the original. We need to do this because of:
-            # http://bugs.python.org/issue10272
-            if 'timed out' in str(e) or \
-               'did not complete (read)' in str(e):  # Python 2.6
-                raise ReadTimeoutError(
-                        self, url, "Read timed out. (read timeout=%s)" % read_timeout)
-
-            raise
-
-        except SocketError as e:  # Platform-specific: Python 2
-            # See the above comment about EAGAIN in Python 3. In Python 2 we
-            # have to specifically catch it and throw the timeout error
-            if e.errno in _blocking_errnos:
-                raise ReadTimeoutError(
-                    self, url, "Read timed out. (read timeout=%s)" % read_timeout)
-
+        except (SocketTimeout, BaseSSLError, SocketError) as e:
+            self._raise_timeout(err=e, url=url, timeout_value=read_timeout)
             raise
 
         # AppEngine doesn't have a version attr.


Test patch:
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
@@ -137,6 +137,24 @@ def socket_handler(listener):
         finally:
             timed_out.set()
 
+    def test_https_connection_read_timeout(self):
+        """ Handshake timeouts should fail with a Timeout"""
+        timed_out = Event()
+        def socket_handler(listener):
+            sock = listener.accept()[0]
+            while not sock.recv(65536):
+                pass
+
+            timed_out.wait()
+            sock.close()
+
+        self._start_server(socket_handler)
+        pool = HTTPSConnectionPool(self.host, self.port, timeout=0.001, retries=False)
+        try:
+            self.assertRaises(ReadTimeoutError, pool.request, 'GET', '/')
+        finally:
+            timed_out.set()
+
     def test_timeout_errors_cause_retries(self):
         def socket_handler(listener):
             sock_timeout = listener.accept()[0]