Repository: ethereum/web3.py

Issue:
`cache_and_return_async_session` has bug in  function at multi thread environment
* Version: 6.0.0b6
* Python: 3.9.13
* OS: osx (intel)
* `pip freeze` output

```
<put the output from running pip freeze here>
aiohttp==3.8.1
aiosignal==1.2.0
anyio==3.6.1
async-timeout==4.0.2
attrs==22.1.0
base58==2.1.1
beautifulsoup4==4.11.1
bitarray==2.6.0
cached-property==1.5.2
certifi==2022.6.15
charset-normalizer==2.1.1
cytoolz==0.12.0
eth-abi==3.0.1
eth-account==0.7.0
eth-bloom==1.0.4
eth-hash==0.3.3
eth-keyfile==0.6.0
eth-keys==0.4.0
eth-rlp==0.3.0
eth-tester==0.7.0b1
eth-typing==3.2.0
eth-utils==2.0.0
frozenlist==1.3.1
hexbytes==0.2.3
idna==3.3
iniconfig==1.1.1
ipfshttpclient==0.8.0a2
jsonschema==4.14.0
lru-dict==1.1.8
multiaddr==0.0.9
multidict==6.0.2
mypy==0.982
mypy-extensions==0.4.3
netaddr==0.8.0
packaging==21.3
parsimonious==0.8.1
pluggy==1.0.0
protobuf==3.20.1
py==1.11.0
py-ecc==6.0.0
py-evm==0.6.0a1
py-geth==3.9.1
pycryptodome==3.15.0
pyethash==0.1.27
pyparsing==3.0.9
pyrsistent==0.18.1
pysha3==1.0.2
pytest==7.1.2
pytest-asyncio==0.19.0
requests==2.28.1
rlp==3.0.0
semantic-version==2.10.0
six==1.16.0
sniffio==1.3.0
sortedcontainers==2.4.0
soupsieve==2.3.2.post1
tomli==2.0.1
toolz==0.12.0
trie==2.0.2
types-requests==2.28.10
types-urllib3==1.26.24
typing-extensions==3.10.0.2
urllib3==1.26.11
varint==1.0.2
web3==6.0.0b6
websockets==10.3
yarl==1.8.1
```
---

### What was wrong?

Error occurred run multi thread and multi event_loop

#### Test Code
~~~python
import asyncio
import typing
import time

from threading import Thread

from web3 import Web3, AsyncHTTPProvider
from web3.eth import AsyncEth
from web3.net import AsyncNet
from web3.geth import Geth, AsyncGethTxPool, AsyncGethPersonal, AsyncGethAdmin


def create_async_web3(node_url: str) -> Web3:
    return Web3(
        AsyncHTTPProvider(node_url),
        modules={
            "eth": (AsyncEth,),
            "net": (AsyncNet,),
            "geth": (
                Geth,
                {
                    "txpool": (AsyncGethTxPool,),
                    "personal": (AsyncGethPersonal,),
                    "admin": (AsyncGethAdmin,),
                },
            ),
        },
        middlewares=[],
    )


async def get_latest_block_number(w3: Web3) -> int:
    w3_eth = typing.cast(AsyncEth, w3.eth)
    latest_block = await w3_eth.get_block("latest")
    return latest_block["number"]


def target_function(w3: Web3, index: int):
    event_loop = asyncio.new_event_loop()

    while True:
        block_number = event_loop.run_until_complete(
            get_latest_block_number(w3)
        )
        print(f"{index}: {block_number}")
        time.sleep(5)


def main():
    threads = []
    w3 = create_async_web3("ETH_NODE_URL")

    for index in range(5):
        thread = Thread(target=target_function, args=(w3, index,), daemon=True)
        thread.start()
        threads.append(thread)

    for thread in threads:
        thread.join()


if __name__ == "__main__":
    main()
~~~

#### Error Message
~~~
Traceback (most recent call last):
  File "/Users/yjk/.pyenv/versions/3.9.13/lib/python3.9/threading.py", line 917, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/yjk/PycharmProjects/ddxt/_test_multit_hread_.py", line 42, in target_function
    block_number = event_loop.run_until_complete(
  File "/Users/yjk/.pyenv/versions/3.9.13/lib/python3.9/asyncio/base_events.py", line 647, in run_until_complete
    return future.result()
  File "/Users/yjk/PycharmProjects/ddxt/_test_multit_hread_.py", line 34, in get_latest_block_number
    latest_block = await w3_eth.get_block("latest")
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/web3/eth.py", line 506, in get_block
    return await self._get_block(  # type: ignore
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/web3/module.py", line 85, in caller
    result = await w3.manager.coro_request(
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/web3/manager.py", line 213, in coro_request
    response = await self._coro_make_request(method, params)
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/web3/manager.py", line 161, in _coro_make_request
    return await request_func(method, params)
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/web3/providers/async_rpc.py", line 82, in make_request
    raw_response = await async_make_post_request(
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/web3/_utils/request.py", line 226, in async_make_post_request
    response = await async_get_response_from_post_request(
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/web3/_utils/request.py", line 218, in async_get_response_from_post_request
    response = await session.post(endpoint_uri, *args, **kwargs)
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/aiohttp/client.py", line 535, in _request
    conn = await self._connector.connect(
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/aiohttp/connector.py", line 542, in connect
    proto = await self._create_connection(req, traces, timeout)
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/aiohttp/connector.py", line 907, in _create_connection
    _, proto = await self._create_direct_connection(req, traces, timeout)
  File "/Users/yjk/PycharmProjects/ddxt/.venv/lib/python3.9/site-packages/aiohttp/connector.py", line 1154, in _create_direct_connection
    hosts = await asyncio.shield(host_resolved)
RuntimeError: Task <Task pending name='Task-2' coro=<get_latest_block_number() running at /Users/yjk/PycharmProjects/ddxt/_test_multit_hread_.py:34> cb=[_run_until_complete_cb() at /Users/yjk/.pyenv/versions/3.9.13/lib/python3.9/asyncio/base_events.py:184]> got Future <Future pending cb=[shield.<locals>._outer_done_callback() at /Users/yjk/.pyenv/versions/3.9.13/lib/python3.9/asyncio/tasks.py:907]> attached to a different loop
~~~

---

### How can it be fixed?

#### Error reason
cached session use other thread's event_loop

#### Fast fix
add thread_id at cache_key

As-Is [[code](https://github.com/ethereum/web3.py/blob/master/web3/_utils/request.py#L152)]
~~~python
# web3/_utils/request.py
async def cache_and_return_async_session(
    endpoint_uri: URI,
    session: Optional[ClientSession] = None,
) -> ClientSession:
    cache_key = generate_cache_key(endpoint_uri)
    ......
~~~

To-Be
~~~ python
# web3/_utils/request.py
async def cache_and_return_async_session(
    endpoint_uri: URI,
    session: Optional[ClientSession] = None,
) -> ClientSession:
    cache_key = generate_cache_key(endpoint_uri) + str(threading.get_ident())
    ......
~~~
 
I didn't know the whole code, so I roughly solved it like this. I would appreciate it if you could solve the bug neatly.

---


Discussion:
Thanks for highlighting the bug @john419. Just getting to this now due to Devcon. I don't think that's an unreasonable solution tbh. We just need a unique identifier. I can put up a PR and try to get this in the next release. Thanks!

Resolving patch:
diff --git a/web3/_utils/request.py b/web3/_utils/request.py
--- a/web3/_utils/request.py
+++ b/web3/_utils/request.py
@@ -74,14 +74,15 @@ def get_default_http_endpoint() -> URI:
     return URI(os.environ.get("WEB3_HTTP_PROVIDER_URI", "http://localhost:8545"))
 
 
-_session_cache = SessionCache(size=20)
+_session_cache = SessionCache(size=100)
 _session_cache_lock = threading.Lock()
 
 
 def cache_and_return_session(
     endpoint_uri: URI, session: requests.Session = None
 ) -> requests.Session:
-    cache_key = generate_cache_key(endpoint_uri)
+    # cache key should have a unique thread identifier
+    cache_key = generate_cache_key(f"{threading.get_ident()}:{endpoint_uri}")
 
     evicted_items = None
     with _session_cache_lock:
@@ -144,7 +145,7 @@ def _close_evicted_sessions(evicted_sessions: List[requests.Session]) -> None:
 # --- async --- #
 
 
-_async_session_cache = SessionCache(size=20)
+_async_session_cache = SessionCache(size=100)
 _async_session_cache_lock = threading.Lock()
 _pool = ThreadPoolExecutor(max_workers=1)
 
@@ -153,7 +154,8 @@ async def cache_and_return_async_session(
     endpoint_uri: URI,
     session: Optional[ClientSession] = None,
 ) -> ClientSession:
-    cache_key = generate_cache_key(endpoint_uri)
+    # cache key should have a unique thread identifier
+    cache_key = generate_cache_key(f"{threading.get_ident()}:{endpoint_uri}")
 
     evicted_items = None
     async with async_lock(_async_session_cache_lock):


Test patch:
diff --git a/tests/core/utilities/test_request.py b/tests/core/utilities/test_request.py
--- a/tests/core/utilities/test_request.py
+++ b/tests/core/utilities/test_request.py
@@ -3,6 +3,7 @@
     ThreadPoolExecutor,
 )
 import pytest
+import threading
 import time
 
 from aiohttp import (
@@ -62,6 +63,14 @@ def check_adapters_mounted(session: Session):
     assert len(session.adapters) == 2
 
 
+def _simulate_call(uri):
+    _session = cache_and_return_session(uri)
+
+    # simulate a call taking 0.01s to return a response
+    time.sleep(0.01)
+    return _session
+
+
 def test_make_post_request_no_args(mocker):
     mocker.patch("requests.Session.post", return_value=MockedResponse())
     request._session_cache.clear()
@@ -71,7 +80,7 @@ def test_make_post_request_no_args(mocker):
     response = request.make_post_request(TEST_URI, data=b"request")
     assert response == "content"
     assert len(request._session_cache) == 1
-    cache_key = generate_cache_key(TEST_URI)
+    cache_key = generate_cache_key(f"{threading.get_ident()}:{TEST_URI}")
     session = request._session_cache.get_cache_entry(cache_key)
     session.post.assert_called_once_with(TEST_URI, data=b"request", timeout=10)
 
@@ -151,7 +160,7 @@ def test_cache_session_class():
 
 def test_cache_does_not_close_session_before_a_call_when_multithreading():
     # save default values
-    session_cache_default = request._async_session_cache
+    session_cache_default = request._session_cache
     timeout_default = request.DEFAULT_TIMEOUT
 
     # set cache size to 1 + set future session close thread time to 0.01s
@@ -159,13 +168,6 @@ def test_cache_does_not_close_session_before_a_call_when_multithreading():
     _timeout_for_testing = 0.01
     request.DEFAULT_TIMEOUT = _timeout_for_testing
 
-    def _simulate_call(uri):
-        _session = cache_and_return_session(uri)
-
-        # simulate a call taking 0.01s to return a response
-        time.sleep(0.01)
-        return _session
-
     with ThreadPoolExecutor(max_workers=len(UNIQUE_URIS)) as exc:
         all_sessions = [exc.submit(_simulate_call, uri) for uri in UNIQUE_URIS]
 
@@ -181,13 +183,31 @@ def _simulate_call(uri):
     cached_session.close()
 
     # reset default values
-    request._async_session_cache = session_cache_default
+    request._session_cache = session_cache_default
     request.DEFAULT_TIMEOUT = timeout_default
 
     # clear cache
     request._session_cache.clear()
 
 
+def test_unique_cache_keys_created_per_thread_with_same_uri():
+    # somewhat inspired by issue #2680
+
+    with ThreadPoolExecutor(max_workers=2) as exc:
+        test_sessions = [exc.submit(_simulate_call, TEST_URI) for _ in range(2)]
+
+    # assert unique keys are generated per thread for the same uri
+    assert len(request._session_cache._data) == 2
+
+    # -- teardown -- #
+
+    # appropriately close the test sessions
+    [session.result().close() for session in test_sessions]
+
+    # clear cache
+    request._session_cache.clear()
+
+
 # -- async -- #
 
 
@@ -262,3 +282,44 @@ async def cache_uri_and_return_session(uri):
 
     # clear cache
     request._async_session_cache.clear()
+
+
+@pytest.mark.asyncio
+async def test_async_unique_cache_keys_created_per_thread_with_same_uri():
+    # inspired by issue #2680
+    # Note: unique event loops for each thread are important here
+
+    # capture the test sessions to appropriately close them in teardown
+    test_sessions = []
+
+    def target_function(endpoint_uri):
+        event_loop = asyncio.new_event_loop()
+        unique_session = event_loop.run_until_complete(
+            cache_and_return_async_session(endpoint_uri)
+        )
+        test_sessions.append(unique_session)
+
+    threads = []
+
+    for _ in range(2):
+        thread = threading.Thread(
+            target=target_function,
+            args=(TEST_URI,),
+            daemon=True,
+        )
+        thread.start()
+        threads.append(thread)
+
+    [thread.join() for thread in threads]
+
+    # assert unique keys are generated per thread, w/ unique event loops,
+    # for the same uri
+    assert len(request._async_session_cache._data) == 2
+
+    # -- teardown -- #
+
+    # appropriately close the test sessions
+    [await session.close() for session in test_sessions]
+
+    # clear cache
+    request._async_session_cache.clear()