Repository: aio-libs/aiohttp

Issue:
Value Error Could not parse hostname from URL 'http://'
## Long story short

When a request response is a redirect but location has an invalid url, a ValueError is raised.

## Expected behaviour

raise a ClientError or some more specific error.

## Actual behaviour
```
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/usr/lib/python3.6/asyncio/base_events.py", line 449, in run_until_complete
    return future.result()
  File "/usr/lib/python3.6/asyncio/tasks.py", line 239, in _step
    result = coro.send(None)
  File "<stdin>", line 4, in process
  File "/myenv/lib/python3.6/site-packages/aiohttp/client.py", line 604, in __aenter__
    self._resp = yield from self._coro
  File "/myenv/lib/python3.6/site-packages/aiohttp/client.py", line 228, in _request
    session=self, auto_decompress=self._auto_decompress)
  File "/myenv/lib/python3.6/site-packages/aiohttp/client_reqrep.py", line 97, in __init__
    self.update_host(url)
  File "/myenv/lib/python3.6/site-packages/aiohttp/client_reqrep.py", line 126, in update_host
    "Could not parse hostname from URL '{}'".format(url))
ValueError: Could not parse hostname from URL 'http://'
```

## Steps to reproduce

```import asyncio
import aiohttp

async def process():
    url = 'http://999pokers.info/'
    session = aiohttp.ClientSession()
    async with session.get(url) as response:
        print(response)
    session.close()

loop = asyncio.get_event_loop()
loop.run_until_complete(
    process()
)
loop.close()
```

## Your environment

Python 3.6.0b2
Ubuntu 16.10
aiohttp==2.2,5
Also happens with aiohttp==2.3.0a0 (installed from git on 04/Sep/2017)


Discussion:
Agree. Would you provide a Pull Request with introducing a new exception class?

Resolving patch:
diff --git a/aiohttp/client.py b/aiohttp/client.py
--- a/aiohttp/client.py
+++ b/aiohttp/client.py
@@ -15,8 +15,8 @@
 from . import connector as connector_mod
 from . import client_exceptions, client_reqrep, hdrs, http, payload
 from .client_exceptions import *  # noqa
-from .client_exceptions import (ClientError, ClientOSError, ServerTimeoutError,
-                                WSServerHandshakeError)
+from .client_exceptions import (ClientError, ClientOSError, InvalidURL,
+                                ServerTimeoutError, WSServerHandshakeError)
 from .client_reqrep import *  # noqa
 from .client_reqrep import ClientRequest, ClientResponse
 from .client_ws import ClientWebSocketResponse
@@ -201,7 +201,15 @@ def _request(self, method, url, *,
                 skip_headers.add(istr(i))
 
         if proxy is not None:
-            proxy = URL(proxy)
+            try:
+                proxy = URL(proxy)
+            except ValueError:
+                raise InvalidURL(url)
+
+        try:
+            url = URL(url)
+        except ValueError:
+            raise InvalidURL(url)
 
         # timeout is cumulative for all request operations
         # (request, redirects, responses, data consuming)
@@ -214,7 +222,7 @@ def _request(self, method, url, *,
         try:
             with timer:
                 while True:
-                    url = URL(url).with_fragment(None)
+                    url = url.with_fragment(None)
                     cookies = self._cookie_jar.filter_cookies(url)
 
                     req = self._request_class(
@@ -280,8 +288,12 @@ def _request(self, method, url, *,
                             # see github.com/aio-libs/aiohttp/issues/2022
                             break
 
-                        r_url = URL(
-                            r_url, encoded=not self.requote_redirect_url)
+                        try:
+                            r_url = URL(
+                                r_url, encoded=not self.requote_redirect_url)
+
+                        except ValueError:
+                            raise InvalidURL(r_url)
 
                         scheme = r_url.scheme
                         if scheme not in ('http', 'https', ''):
diff --git a/aiohttp/client_exceptions.py b/aiohttp/client_exceptions.py
--- a/aiohttp/client_exceptions.py
+++ b/aiohttp/client_exceptions.py
@@ -15,7 +15,7 @@
     'ClientResponseError', 'ClientHttpProxyError',
     'WSServerHandshakeError', 'ContentTypeError',
 
-    'ClientPayloadError')
+    'ClientPayloadError', 'InvalidURL')
 
 
 class ClientError(Exception):
@@ -131,3 +131,22 @@ def __repr__(self):
 
 class ClientPayloadError(ClientError):
     """Response payload error."""
+
+
+class InvalidURL(ClientError, ValueError):
+    """Invalid URL.
+
+    URL used for fetching is malformed, e.g. it doesn't contains host
+    part."""
+
+    # Derive from ValueError for backward compatibility
+
+    def __init__(self, url):
+        super().__init__(url)
+
+    @property
+    def url(self):
+        return self.args[0]
+
+    def __repr__(self):
+        return '<{} {}>'.format(self.__class__.__name__, self.url)
diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py
--- a/aiohttp/client_reqrep.py
+++ b/aiohttp/client_reqrep.py
@@ -13,7 +13,8 @@
 
 from . import hdrs, helpers, http, payload
 from .client_exceptions import (ClientConnectionError, ClientOSError,
-                                ClientResponseError, ContentTypeError)
+                                ClientResponseError, ContentTypeError,
+                                InvalidURL)
 from .formdata import FormData
 from .helpers import PY_35, HeadersMixin, SimpleCookie, TimerNoop, noop
 from .http import SERVER_SOFTWARE, HttpVersion10, HttpVersion11, PayloadWriter
@@ -122,8 +123,7 @@ def update_host(self, url):
         """Update destination host, port and connection type (ssl)."""
         # get host/port
         if not url.host:
-            raise ValueError(
-                "Could not parse hostname from URL '{}'".format(url))
+            raise InvalidURL(url)
 
         # basic auth info
         username, password = url.user, url.password


Test patch:
diff --git a/tests/test_client_exceptions.py b/tests/test_client_exceptions.py
--- a/tests/test_client_exceptions.py
+++ b/tests/test_client_exceptions.py
@@ -1,5 +1,7 @@
 """Tests for http_exceptions.py"""
 
+from yarl import URL
+
 from aiohttp import client
 
 
@@ -8,3 +10,11 @@ def test_fingerprint_mismatch():
     expected = ('<ServerFingerprintMismatch expected=exp'
                 ' got=got host=host port=8888>')
     assert expected == repr(err)
+
+
+def test_invalid_url():
+    url = URL('http://example.com')
+    err = client.InvalidURL(url)
+    assert err.args[0] is url
+    assert err.url is url
+    assert repr(err) == "<InvalidURL http://example.com>"
diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py
--- a/tests/test_client_functional.py
+++ b/tests/test_client_functional.py
@@ -2216,3 +2216,13 @@ def handler_redirect(request):
 
     with pytest.raises(aiohttp.ClientResponseError):
         yield from client.get('/')
+
+
+@asyncio.coroutine
+def test_invalid_idna(loop):
+    session = aiohttp.ClientSession(loop=loop)
+    try:
+        with pytest.raises(aiohttp.InvalidURL):
+            yield from session.get('http://\u2061owhefopw.com')
+    finally:
+        yield from session.close()
diff --git a/tests/test_client_request.py b/tests/test_client_request.py
--- a/tests/test_client_request.py
+++ b/tests/test_client_request.py
@@ -285,15 +285,10 @@ def test_headers_default(make_request):
 
 
 def test_invalid_url(make_request):
-    with pytest.raises(ValueError):
+    with pytest.raises(aiohttp.InvalidURL):
         make_request('get', 'hiwpefhipowhefopw')
 
 
-def test_invalid_idna(make_request):
-    with pytest.raises(ValueError):
-        make_request('get', 'http://\u2061owhefopw.com')
-
-
 def test_no_path(make_request):
     req = make_request('get', 'http://python.org')
     assert '/' == req.url.path