Repository: aio-libs/aiohttp

Issue:
GET Requests to link-local IPv6 addresses don't work on Python 3.7+
🐞 **Describe the bug**
The aiohttp resolver loses information related to linklocal IPv6 addresses on Python 3.7+ due to a changes in the representation returned by `socket.getaddrinfo()`

💡 **To Reproduce**
Try to get an URL like `http://[fe80::1%eth0]:8080/`, it will result in an OSError (Invalid argument) exception.

This seems to be due to the way that scopeid's are handled in [resolver.py](https://github.com/aio-libs/aiohttp/blob/72c2acd4850b1cbc638b413a7c28d96882b4d7e8/aiohttp/resolver.py#L31-L37):

Run `socket.getaddrinfo('fe80::1%eth0', 8080, family=socket.AF_INET6, proto=socket.IPPROTO_TCP)[0][4]` on python 3.6:
```python
socket.getaddrinfo('fe80::1%eth0', 8080, family=socket.AF_INET6, proto=socket.IPPROTO_TCP)[0][4]
>>> socket.getaddrinfo('fe80::1%eth0', 8080, family=socket.AF_INET6, proto=socket.IPPROTO_TCP)[0][4]
('fe80::1%eth0', 8080, 0, 4)
```

Run it on python 3.7:
```python
>>> socket.getaddrinfo('fe80::1%eth0', 8080, family=socket.AF_INET6, proto=socket.IPPROTO_TCP)[0][4]
('fe80::1', 8080, 0, 4)y
```

The `address` element of the tuple no longer includes the textual representation of the scope id, it's only contained in the matching scope_id element of the tuple - which then is missing when later callings _loop.create_connection().

💡 **Expected behavior**
The URL is successfully retrieved for link local IPv6 addresses.


📋 **Logs/tracebacks**
```python-traceback (paste your traceback in the next line)
N/A
```

📋 **Your version of the Python**
```console
$ python3 --version
Python 3.6.6
$ python3.7 --version
Python 3.7.5
```

📋 **Your version of the aiohttp/yarl/multidict distributions**
```console
$ python -m pip show aiohttp
python -m pip show aiohttp
Name: aiohttp
Version: 3.6.2
```
```console
$ python -m pip show multidict
Name: multidict
Version: 4.7.4
```
```console
$ python -m pip show yarl
Name: yarl
Version: 1.4.2
```

📋 **Additional context**
OS: Centos7 Linux
Proxy Server: No
Related to: client


Discussion:
https://bugs.python.org/issue35545#msg332275 more context around asycio create_server and how getaddrinfo is not broken in 3.7+ and the behavior is the new norm.   Though create_server does not accept a scopeid :(,  you have to pass the ipv6 address with %scope to create_server host field. 
Yep. This was the change I've done in Python. Tuple actually contains information about scope Id (4 - is the interface index of your network interface eth0). So, `%eth0` is superfluous. Actually, the first tuple element should be a sequence of 16 bytes, not a string in IPv6 address form, but unfortunately, by compatibility reasons, it uses this form.

Possibly, you should use `getnameinfo()`:

```python
In [16]: socket.getnameinfo(('fe80::841e:8fff:fea4:4f19', 456, 0, 3), socket.AI_NUMERICHOST)
Out[16]: ('fe80::841e:8fff:fea4:4f19%wlp2s0', '456')
```

Resolving patch:
diff --git a/aiohttp/resolver.py b/aiohttp/resolver.py
--- a/aiohttp/resolver.py
+++ b/aiohttp/resolver.py
@@ -31,11 +31,23 @@ async def resolve(self, host: str, port: int=0,
 
         hosts = []
         for family, _, proto, _, address in infos:
-            hosts.append(
-                {'hostname': host,
-                 'host': address[0], 'port': address[1],
-                 'family': family, 'proto': proto,
-                 'flags': socket.AI_NUMERICHOST})
+            if family == socket.AF_INET6 and address[3]:  # type: ignore
+                # This is essential for link-local IPv6 addresses.
+                # LL IPv6 is a VERY rare case. Strictly speaking, we should use
+                # getnameinfo() unconditionally, but performance makes sense.
+                host, _port = socket.getnameinfo(
+                    address, socket.NI_NUMERICHOST | socket.NI_NUMERICSERV)
+                port = int(_port)
+            else:
+                host, port = address[:2]
+            hosts.append({
+                'hostname': host,
+                'host': host,
+                'port': port,
+                'family': family,
+                'proto': proto,
+                'flags': socket.AI_NUMERICHOST | socket.AI_NUMERICSERV,
+            })
 
         return hosts
 
@@ -62,11 +74,14 @@ async def resolve(self, host: str, port: int=0,
             raise OSError(msg) from exc
         hosts = []
         for address in resp.addresses:
-            hosts.append(
-                {'hostname': host,
-                 'host': address, 'port': port,
-                 'family': family, 'proto': 0,
-                 'flags': socket.AI_NUMERICHOST})
+            hosts.append({
+                'hostname': host,
+                'host': address,
+                'port': port,
+                'family': family,
+                'proto': 0,
+                'flags': socket.AI_NUMERICHOST | socket.AI_NUMERICSERV,
+            })
 
         if not hosts:
             raise OSError("DNS lookup failed")


Test patch:
diff --git a/tests/test_connector.py b/tests/test_connector.py
--- a/tests/test_connector.py
+++ b/tests/test_connector.py
@@ -652,10 +652,10 @@ async def test_tcp_connector_resolve_host(loop) -> None:
     for rec in res:
         if rec['family'] == socket.AF_INET:
             assert rec['host'] == '127.0.0.1'
-            assert rec['hostname'] == 'localhost'
+            assert rec['hostname'] == '127.0.0.1'
             assert rec['port'] == 8080
         elif rec['family'] == socket.AF_INET6:
-            assert rec['hostname'] == 'localhost'
+            assert rec['hostname'] == '::1'
             assert rec['port'] == 8080
             if platform.system() == 'Darwin':
                 assert rec['host'] in ('::1', 'fe80::1', 'fe80::1%lo0')