Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Requests with IPv6 Link-Local address and Zone Identifier fail #10314

Open
1 task done
ph1l1p139 opened this issue Jan 9, 2025 · 7 comments
Open
1 task done

Requests with IPv6 Link-Local address and Zone Identifier fail #10314

ph1l1p139 opened this issue Jan 9, 2025 · 7 comments

Comments

@ph1l1p139
Copy link

Describe the bug

With aiohttp versions from 3.10 onwards, HTTP client requests to IPv6 link-local addresses with Zone Identifiers no longer work.

Previous versions (3.7 and 3.9 tested) work when using zone Identifiers.
I can't see any information about this change in behaviour in the changelogs so I think it is a bug.

IPv6 Zone identifiers in address literals/URIs are described in RFC6874. They specify a specific network interface to use when communicating with scoped IPv6 addresses wiki. On windows the interface is identified by the ifIndex from Get-NetAdapter in Powershell, in Linux it is the interface name.

In previous versions a request to a URL of the form https://[fe80::255:daff:fe40:6158%5]:8092/SerialNumber would work (although it should be noted that this is not actually RFC compliant as the % literal is not escaped as %25).

In current versions the request times out without sending any packets (checked in wireshark) and a aiohttp.client_exceptions.ClientConnectorError is raised.

To Reproduce

  1. Set up an IPv6 capable device as a server on your local network and record the IPv6 link local address (starts fe80::).
  2. Identify the zone index of the network interface attached to the local network with the above device. On windows you can use Get-NetAdapter and record the ifIndex, on Linux use ifconfig to get the interface name (e.g eth1).
  3. Implement the client as below, modifying the IPv6 address and zone identifier to match those recorded above, and modifying the rest of the URL to match an endpoint on your server.
  4. Run the client to send the request.
import asyncio
import aiohttp

async def get_serial():
    url = "https://[fe80::255:daff:fe40:6158%5]:8092/SerialNumber"
    headers = {'Content-Type': 'application/json'}

    print(f"Attempting to GET {url}")

    async with aiohttp.ClientSession() as session:
        async with session.get(url, headers=headers ) as resp:
            if resp.status != 200:
                raise ResponseError(f"Received unexpected response {resp.status} from {url}")
            parsed = await resp.json()

    print(f"Server has serial number {parsed['SerialNumber']}")

if __name__ == "__main__":
    asyncio.run(get_serial())

Expected behavior

A HTTP request is sent to the server and the response is received correctly.

Logs/tracebacks

> uv run -p 3.13 .\aiohttp-testcase.py
Reading inline script metadata from `aiohttp-testcase.py`
Attempting to GET https://[fe80::255:daff:fe40:6158%5]:8092/SerialNumber
Traceback (most recent call last):
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\connector.py", line 1115, in _wrap_create_connection
    sock = await aiohappyeyeballs.start_connection(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<5 lines>...
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohappyeyeballs\impl.py", line 104, in start_connection
    raise first_exception
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohappyeyeballs\impl.py", line 82, in start_connection
    sock = await _connect_sock(
           ^^^^^^^^^^^^^^^^^^^^
        current_loop, exceptions, addrinfo, local_addr_infos
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohappyeyeballs\impl.py", line 174, in _connect_sock
    await loop.sock_connect(sock, address)
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\proactor_events.py", line 726, in sock_connect
    return await self._proactor.connect(sock, address)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\windows_events.py", line 804, in _poll
    value = callback(transferred, key, ov)
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\windows_events.py", line 600, in finish_connect
    ov.getresult()
    ~~~~~~~~~~~~^^
OSError: [WinError 121] The semaphore timeout period has expired

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\git\Aligo\DS10G\yocto\scripts\nsg-upgrade\aiohttp-testcase.py", line 34, in <module>
    asyncio.run(get_serial())
    ~~~~~~~~~~~^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\runners.py", line 194, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\base_events.py", line 720, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "C:\git\Aligo\DS10G\yocto\scripts\nsg-upgrade\aiohttp-testcase.py", line 26, in get_serial
    async with session.get(url, headers=headers, ssl=ssl_context ) as resp:
               ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\client.py", line 1425, in __aenter__
    self._resp: _RetType = await self._coro
                           ^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\client.py", line 703, in _request
    conn = await self._connector.connect(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        req, traces=traces, timeout=real_timeout
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\connector.py", line 548, in connect
    proto = await self._create_connection(req, traces, timeout)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\connector.py", line 1056, in _create_connection
    _, proto = await self._create_direct_connection(req, traces, timeout)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\connector.py", line 1400, in _create_direct_connection
    raise last_exc
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\connector.py", line 1369, in _create_direct_connection
    transp, proto = await self._wrap_create_connection(
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<7 lines>...
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\zZOczEdLHPBOvMpn0PUvM\Lib\site-packages\aiohttp\connector.py", line 1130, in _wrap_create_connection
    raise client_error(req.connection_key, exc) from exc
aiohttp.client_exceptions.ClientConnectorError: Cannot connect to host fe80::255:daff:fe40:6158%5:8092 ssl:<ssl.SSLContext object at 0x0000029E21368B00> [The semaphore timeout period has expired]

Python Version

3.13.1

aiohttp Version

3.11.11

multidict Version

6.1.0

propcache Version

0.2.1

yarl Version

1.18.3

OS

Windows 11

Related component

Client

Additional context

This behaviour is observed using IPv6 link local addresses which are on the local network. The internet and proxies etc are not involved.

My specific test case uses https as our server requires SSL with a self signed certificate but I don't think this is related to the issue as no network packets are ever being sent.

Code of Conduct

  • I agree to follow the aio-libs Code of Conduct
@ph1l1p139 ph1l1p139 added the bug label Jan 9, 2025
@Dreamsorcerer
Copy link
Member

I can't see any information about this change in behaviour in the changelogs so I think it is a bug.

Probably:

Restored AsyncResolver to be the default resolver.

I guess it's a feature missing in aiodns.

@Dreamsorcerer
Copy link
Member

You should be able to use the ThreadedResolver explicitly in the connector as a workaround, which should get you the same behaviour as previous versions.

@bdraco
Copy link
Member

bdraco commented Jan 9, 2025

It looks like proactor is being used on windows so unless aiodns is installed and selector event loop is being used, I think something else is going on here as I would expect ThreadedResolver is already being used in this case.

@bdraco
Copy link
Member

bdraco commented Jan 9, 2025

Maybe related to #9032

@ph1l1p139
Copy link
Author

Thank you for your quick responses.

I think as @bdraco says the ThreadedResolver is being used by default.

If I use the AsyncResolver it seems to work. (other changes in code from the earlier example are to get it working on my specific setup with uv to manage dependencies and a self signed certificate on the server etc).

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "aiodns",
#     "aiohttp",
#     "termcolor",
#     "websockets",
#     "winloop",
# ]
# ///

import asyncio
import ssl
import sys

import aiohttp
from aiohttp.resolver import AsyncResolver

async def get_serial():
    url = "https://[fe80::255:daff:fe40:6158%5]:8092/SerialNumber"
    headers = {'Content-Type': 'application/json'}

    print(f"Attempting to GET {url}")

    resolver = AsyncResolver()
    conn = aiohttp.TCPConnector(resolver=resolver, ssl=False)
    async with aiohttp.ClientSession(connector=conn) as session:
        async with session.get(url, headers=headers ) as resp:
            if resp.status != 200:
                raise ResponseError(f"Received unexpected response {resp.status} from {url}")
            parsed = await resp.json()

    print(f"Server has serial number {parsed['SerialNumber']}")

if __name__ == "__main__":

    if sys.platform == 'win32':
	    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

    asyncio.run(get_serial())

I can use this as a work around for now though.

@ph1l1p139
Copy link
Author

Scratch that it's not quite working as a workaround in my larger codebase for a seemingly unrelated reason - a large post request (>100MB) seems to now stall and get a 400 response from our server after switching to AsyncResolver.

@ph1l1p139
Copy link
Author

Sorry for sending so many updates. But this is getting weirder.

If I explicitly set ThreadedResolver but use WindowsSelectorEventLoopPolicy it seems to work:

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "aiohttp",
#     "termcolor",
#     "websockets",
#     "winloop",
# ]
# ///

import asyncio
import ssl
import sys

import aiohttp
from aiohttp.resolver import ThreadedResolver

async def get_serial():
    url = "https://[fe80::255:daff:fe40:6158%5]:8092/SerialNumber"
    headers = {'Content-Type': 'application/json'}

    print(f"Attempting to GET {url}")

    resolver = ThreadedResolver()
    conn = aiohttp.TCPConnector(resolver=resolver, ssl=False)
    async with aiohttp.ClientSession(connector=conn) as session:
        async with session.get(url, headers=headers ) as resp:
            if resp.status != 200:
                raise ResponseError(f"Received unexpected response {resp.status} from {url}")
            parsed = await resp.json()

    print(f"Server has serial number {parsed['SerialNumber']}")

if __name__ == "__main__":

    if sys.platform == 'win32':
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
        pass

    asyncio.run(get_serial())

But if I comment out asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) and leave everything else the same it breaks:

PS C:\git\Aligo\DS10G\yocto\scripts> uv run .\aiohttp-test.py
Reading inline script metadata from `aiohttp-test.py`
Attempting to GET https://[fe80::255:daff:fe40:6158%5]:8092/SerialNumber
Traceback (most recent call last):
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\connector.py", line 1115, in _wrap_create_connection
    sock = await aiohappyeyeballs.start_connection(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<5 lines>...
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohappyeyeballs\impl.py", line 104, in start_connection
    raise first_exception
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohappyeyeballs\impl.py", line 82, in start_connection
    sock = await _connect_sock(
           ^^^^^^^^^^^^^^^^^^^^
        current_loop, exceptions, addrinfo, local_addr_infos
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohappyeyeballs\impl.py", line 174, in _connect_sock
    await loop.sock_connect(sock, address)
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\proactor_events.py", line 726, in sock_connect
    return await self._proactor.connect(sock, address)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\windows_events.py", line 804, in _poll
    value = callback(transferred, key, ov)
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\windows_events.py", line 600, in finish_connect
    ov.getresult()
    ~~~~~~~~~~~~^^
OSError: [WinError 121] The semaphore timeout period has expired

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\git\Aligo\DS10G\yocto\scripts\aiohttp-test.py", line 40, in <module>
    asyncio.run(get_serial())
    ~~~~~~~~~~~^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\runners.py", line 194, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "C:\Users\PhilipD\AppData\Roaming\uv\python\cpython-3.13.1-windows-x86_64-none\Lib\asyncio\base_events.py", line 720, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "C:\git\Aligo\DS10G\yocto\scripts\aiohttp-test.py", line 28, in get_serial
    async with session.get(url, headers=headers ) as resp:
               ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\client.py", line 1425, in __aenter__
    self._resp: _RetType = await self._coro
                           ^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\client.py", line 703, in _request
    conn = await self._connector.connect(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        req, traces=traces, timeout=real_timeout
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\connector.py", line 548, in connect
    proto = await self._create_connection(req, traces, timeout)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\connector.py", line 1056, in _create_connection
    _, proto = await self._create_direct_connection(req, traces, timeout)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\connector.py", line 1400, in _create_direct_connection
    raise last_exc
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\connector.py", line 1369, in _create_direct_connection
    transp, proto = await self._wrap_create_connection(
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<7 lines>...
    )
    ^
  File "C:\Users\PhilipD\AppData\Local\uv\cache\archive-v0\wZ6TAinz7nSzDWTBgIhja\Lib\site-packages\aiohttp\connector.py", line 1130, in _wrap_create_connection
    raise client_error(req.connection_key, exc) from exc
aiohttp.client_exceptions.ClientConnectorError: Cannot connect to host fe80::255:daff:fe40:6158%5:8092 ssl:default [The semaphore timeout period has expired]

If I run under WSL (mirrored mode networking) with the original code but using conn = aiohttp.TCPConnector(ssl=False) and changing the interface name it works as well!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants