Repository: ethereum/web3.py

Issue:
Async concurrency regression with 5.29.0
* Version: 5.29.0
* Python: 3.9.7
* OS: macos

### What was wrong?

Async code with the AsyncHTTPProvider hangs in 5.29.0; previously worked fine in 5.28.0. 

(Shoddy) code sample:
```py
import asyncio
from web3 import Web3, AsyncHTTPProvider
from web3.eth import AsyncEth
from web3.net import AsyncNet
from web3.middleware import async_geth_poa_middleware

networks = {
    'ARBITRUM' : 'https://arb1.arbitrum.io/rpc',
    'AURORA'   : 'https://mainnet.aurora.dev',
    'AVAX'     : 'https://rpc.ankr.com/avalanche/',
    'BSC'      : 'https://bscrpc.com',
    'CELO'     : 'https://rpc.ankr.com/celo/',
    'CRONOS'   : 'https://evm-cronos.crypto.org',
    'FANTOM'   : 'https://rpc.ankr.com/fantom/',
    'FUSE'     : 'https://rpc.fuse.io/',
    'HARMONY'  : 'https://rpc.ankr.com/harmony/',
    'HECO'     : 'https://http-mainnet.hecochain.com',
    'METIS'    : 'https://andromeda.metis.io/?owner=1088',
    'MOONBEAM' : 'https://rpc.api.moonbeam.network',
    'MOONRIVER': 'https://rpc.api.moonriver.moonbeam.network/',
    'POLYGON'  : 'https://polygon-rpc.com/'
}

async def load_networks():
    async def connect_rpc(network, uri):
        rpc = Web3(
            AsyncHTTPProvider(
                uri,
                request_kwargs={'timeout': 10},
            ),
            modules={'eth': AsyncEth, 'net': AsyncNet},
            middlewares=[]
        ) 
        rpc.middleware_onion.inject(async_geth_poa_middleware, layer=0)

        try:
            chain_id = await rpc.eth.chain_id
            print(network, chain_id)
        except Exception as e:
            print(network, e)

    tasks = [connect_rpc(chain, info) for chain, info in networks.items()]
    await asyncio.gather(*tasks)

if __name__ == "__main__":
    asyncio.run(load_networks())
```

With 5.28.0, the output is as expected.
```
BSC 56
AURORA 1313161554
POLYGON 137
HECO 128
MOONBEAM 1284
MOONRIVER 1285
ARBITRUM 42161
METIS 1088
AVAX 43114
FANTOM 250
CELO 42220
HARMONY 1666600000
FUSE 122
CRONOS 25
```

When updating to 5.29.0, the output hangs. When I abort the program, the error output references a session cache lock (edited for readability)

```
KeyboardInterrupt
Task exception was never retrieved
future: <Task finished name='Task-1' coro=<load_networks() done, defined at []/async_test.py:25> exception=KeyboardInterrupt()>
Traceback (most recent call last):
  File "[]/async_test.py", line 49, in <module>
    asyncio.run(load_networks())
  File "[]/python3.9/asyncio/runners.py", line 47, in run
    _cancel_all_tasks(loop)
  File "[]/python3.9/asyncio/runners.py", line 63, in _cancel_all_tasks
    loop.run_until_complete(
  File "[]/python3.9/asyncio/base_events.py", line 629, in run_until_complete
    self.run_forever()
  File "[]/python3.9/asyncio/base_events.py", line 596, in run_forever
    self._run_once()
  File "[]/python3.9/asyncio/base_events.py", line 1890, in _run_once
    handle._run()
  File "[]/python3.9/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "[]/async_test.py", line 46, in load_networks
    await asyncio.gather(*tasks)
  File "[]/python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "[]/python3.9/asyncio/base_events.py", line 629, in run_until_complete
    self.run_forever()
  File "[]/python3.9/asyncio/base_events.py", line 596, in run_forever
    self._run_once()
  File "[]/python3.9/asyncio/base_events.py", line 1890, in _run_once
    handle._run()
  File "[]/python3.9/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "[]/async_test.py", line 38, in connect_rpc
    chain_id = await rpc.eth.chain_id
  File "[]/python3.9/site-packages/web3/eth.py", line 360, in chain_id
    return await self._chain_id()  # type: ignore
  File "[]/python3.9/site-packages/web3/module.py", line 72, in caller
    result = await w3.manager.coro_request(method_str,
  File "[]/python3.9/site-packages/web3/manager.py", line 213, in coro_request
    response = await self._coro_make_request(method, params)
  File "[]/python3.9/site-packages/web3/manager.py", line 160, in _coro_make_request
    return await request_func(method, params)
  File "[]/python3.9/site-packages/web3/middleware/formatting.py", line 137, in middleware
    response = await make_request(method, params)
  File "[]/python3.9/site-packages/web3/providers/async_rpc.py", line 80, in make_request
    raw_response = await async_make_post_request(
  File "[]/python3.9/site-packages/web3/_utils/request.py", line 113, in async_make_post_request
    session = await _get_async_session(endpoint_uri)
  File "[]/python3.9/site-packages/web3/_utils/request.py", line 94, in _get_async_session
    await cache_async_session(endpoint_uri, ClientSession(raise_for_status=True))
  File "[]/python3.9/site-packages/web3/_utils/request.py", line 68, in cache_async_session
    with _async_session_cache_lock:
KeyboardInterrupt
Unclosed client session
client_session: <aiohttp.client.ClientSession object at 0x106ffafd0>
```

I do see that some SessionCache code [is included in 5.29.0](https://github.com/ethereum/web3.py/compare/v5.28.0...v5.29.0#diff-da1c04440e0604ceb9d314eec7151604f68036eaf4e6206f78f7aa38e36b0a65). My use case may be quite different than what was intended by the Session Cache and is likely related to my report in https://github.com/ethereum/web3.py/pull/2254 (ack, my apologies for not following up on that, @kclowes!). 


Discussion:
@DefiDebauchery any chance you can try what is in #2409 and see if you still have this problem. If it does, I can put in a PR to add this same change in the v5 branch.
> @DefiDebauchery any chance you can try what is in #2409 and see if you still have this problem. If it does, I can put in a PR to add this same change in the v5 branch.

Unfortunately, this does not change the results. Different line numbers for error output, though:

```
  File "[]/web3/middleware/formatting.py", line 137, in middleware
    response = await make_request(method, params)
  File "[]/web3/providers/async_rpc.py", line 80, in make_request
    raw_response = await async_make_post_request(
  File "[]/web3/_utils/request.py", line 124, in async_make_post_request
    session = await _get_async_session(endpoint_uri)
  File "[]/web3/_utils/request.py", line 87, in _get_async_session
    with _async_session_cache_lock:
KeyboardInterrupt
```
oop, thanks for the report @DefiDebauchery. Putting this near the top of my list to look at this week.
@DefiDebauchery the default cache size is 8 so you have to bump your cache size to 14 or however many connections you're trying to make. I didn't look very closely but I don't _think_ there's a straightforward API to do that right at the moment, but I can get to it in a little bit or review if someone else gets to it first.

Edit: [Here](https://github.com/ethereum/web3.py/blob/master/web3/_utils/request.py#L81) is where it gets set. I do think we need to allow users to configure this, but I don't really have a good idea about where to allow that customization. First thought is to allow a `cache_size` kwarg to the provider initialization. To steal your example, something like: 

```python
rpc = Web3(
            AsyncHTTPProvider(
                uri,
                request_kwargs={'timeout': 10},
                cache_size=20,
            ),
            modules={'eth': AsyncEth, 'net': AsyncNet},
            middlewares=[]
        ) 
```

Open to other ideas too!
It _seems_ awkward to have each individual provider instance explicitly set the cache size. Am I wrong that SessionCache is initialized once? So wouldn't it make more sense to instantiate it with the requested size, then pass the instance into the Provider?

Pedantically, I guess it's 6 in one hand / half-dozen in the other - but any ability to modify this is certainly welcomed.
@DefiDebauchery if you change the cache size does that fix the issue. You are correct the cache is only instantiated once across all providers so we need to think what is the best way to make that configurable.

One short term fix could be to bump the default cache size to something higher like 20 or 30. It doesn't seem like that would be to much of a memory issue it would be more of an issue with holding all those connections open simultaneously. 

We could also maybe make request an instance on each provider instead of using it as a singletons but that seems way more intrusive and probably significant work.

@kclowes thoughts?

@dbfreem Yes, taking your file from #2409 and simply changing the cache size does indeed fix the issue. If there are no performance implications, having a higher hardcoded value (30) would be a good stepping stone.
I think bumping the hard coded value to 20 or 30 seems like a good step for now. I'll get that PRed in a little bit.

Right now, the API looks like: 

```py
w3 = Web3(
            AsyncHTTPProvider(
                uri,
                request_kwargs={'timeout': 10},
            ),
            modules={'eth': AsyncEth, 'net': AsyncNet},
            middlewares=[]
        ) 
custom_session = ClientSession()  # If you want to pass in your own session
await w3.provider.cache_async_session(custom_session)
```
@DefiDebauchery are you suggesting something like:
```py
session_cache = SessionCache(size=25)
w3 = Web3(
            AsyncHTTPProvider(
                uri,
                request_kwargs={'timeout': 10},
                session_cache,
            ),
            modules={'eth': AsyncEth, 'net': AsyncNet},
            middlewares=[]
        )
```

Tagging @fselmo for opinions too!
@kclowes Either that, or allowing `ClientSession` in your first example to accept the cache size.
My structural python is fairly non-existent, so ultimately I'm okay with whatever 😬 
@DefiDebauchery I bumped the default cache size to 20 in v5.29.1 as a band-aid fix. I'm currently looking into what the actual fix might be. 
```
async def cache_async_session(endpoint_uri: URI, session: ClientSession) -> None:
    cache_key = generate_cache_key(endpoint_uri)
    evicted_items: Dict[str, Any] = None
    with _async_session_cache_lock:
        evicted_items = _async_session_cache.cache(cache_key, session)
    if evicted_items is not None:
        for key, session in evicted_items.items():
            await session.close()
```

@kclowes this is the fix for the async cache bug. I am closing the connection outside of the lock. What was happening was that since the ClientSession.close() is async it was awaiting that method and passing off the execution to the next async task. Well the first task was still stuck inside of the thread lock and a dead lock was occurring. 

After stepping through here more I realized it is SUPER important for the session cache to be higher than the number of sessions needed. If it is not then it will continue to cycle sessions in and out of the cache. Once the await finally finishes the ClientSession.close() the session being returned may not even be in the cache anymore because it was evicted by a different task. This means there is still a race condition here where a session could be returned from the cache and then evicted by another task which then closes it before it can be used. 

I almost think we should programmatically set the cache size and emit a warning if the cache grows over a certain size and then have a second hard limit where it can't grow above. couple of things to think about is that there could be some sort of connection limit on the underlying OS that we might hit, and memory constrained apps might have issues.

I also wonder if we should do something like [this](https://docs.python.org/3/library/asyncio-sync.html#asyncio.Lock). It is not thread safe but are consumers going to mix threading and asyncio execution. If the consumer will be doing either threading on the blocking request or asyncio on the aiohttp session then maybe we should move to the above linked approach for the async cache. 

Thoughts??
@dbfreem I have not forgotten about this! I hope to get to a more thorough review of this comment and the outstanding session PR this week. I think we want to try and have it be threadsafe, but I need to spend some time thinking about how that might work 🤔 
I will try to take a look this week as well @kclowes 👀 
Resolved in #2409 for web3.py `v6`. This needs a `v5` backport PR so keeping this open for now.
@fselmo @kclowes #2409 would fix the caching issue but should another issue be opened to track making the cache size configurable by the client?
@dbfreem do you think raising the cache size to a reasonable higher value is enough for your needs or do you specifically want to be able to set the cache size? I think with PR #2409 and a reasonably high cache size 99% of cases should not have issues. Open to looking into configuring the cache size though, it just might take a back seat for a bit on our end.
Being able to set the cache easily would be really helpful. Right now, cache size is 20, and at the moment, I'm running 16 concurrent endpoints to monitor the chains that our platform has a presence. This is set to increase within the next 6 months.

Granted, I could hardcode a change, but being able to set or otherwise override this in 'userspace' would be better.
Yeah I'd definitely say 20 is on the low side considering how little memory that takes up. I imagine increasing this number to 50 or above for now. We'd definitely welcome a PR for customizing the cache size otherwise we can make an issue to track and we can get to it when we have some more time as I see this as a nice-to-have feature.

I'm all for making the library as customizable as possible as long as it makes sense to so I'd be onboard with that.
closed by #2409
Side note @DefiDebauchery, cache size is going up to `100` in PR #2690... still not at custom sessions but should be a pretty good buffer.
Ah, I forgot this is in `v5`... re-opening. I'll try to get a back-port for those changes in.

Resolving patch:
diff --git a/web3/_utils/request.py b/web3/_utils/request.py
--- a/web3/_utils/request.py
+++ b/web3/_utils/request.py
@@ -1,11 +1,13 @@
 from collections import (
     OrderedDict,
 )
+import logging
 import os
 import threading
 from typing import (
     Any,
     Dict,
+    List,
 )
 
 from aiohttp import (
@@ -15,16 +17,18 @@
 from eth_typing import (
     URI,
 )
-import lru
 import requests
 
 from web3._utils.caching import (
     generate_cache_key,
 )
 
+logger = logging.getLogger(__name__)
+
+DEFAULT_TIMEOUT = 10
 
-class SessionCache:
 
+class SessionCache:
     def __init__(self, size: int):
         self._size = size
         self._data: OrderedDict[str, Any] = OrderedDict()
@@ -47,6 +51,9 @@ def cache(self, key: str, value: Any) -> Dict[str, Any]:
     def get_cache_entry(self, key: str) -> Any:
         return self._data[key]
 
+    def clear(self) -> None:
+        self._data.clear()
+
     def __contains__(self, item: str) -> bool:
         return item in self._data
 
@@ -55,64 +62,96 @@ def __len__(self) -> int:
 
 
 def get_default_http_endpoint() -> URI:
-    return URI(os.environ.get('WEB3_HTTP_PROVIDER_URI', 'http://localhost:8545'))
+    return URI(os.environ.get("WEB3_HTTP_PROVIDER_URI", "http://localhost:8545"))
+
+
+_session_cache = SessionCache(size=100)
+_session_cache_lock = threading.Lock()
 
 
 def cache_session(endpoint_uri: URI, session: requests.Session) -> None:
-    cache_key = generate_cache_key(endpoint_uri)
-    _session_cache[cache_key] = session
+    # 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:
+        if cache_key not in _session_cache:
+            if session is None:
+                session = requests.Session()
+
+            evicted_items = _session_cache.cache(cache_key, session)
+            logger.debug(f"Session cached: {endpoint_uri}, {session}")
+
+    if evicted_items is not None:
+        evicted_sessions = evicted_items.values()
+        for evicted_session in evicted_sessions:
+            logger.debug(
+                f"Session cache full. Session evicted from cache: {evicted_session}",
+            )
+        threading.Timer(
+            DEFAULT_TIMEOUT + 0.1,
+            _close_evicted_sessions,
+            args=[evicted_sessions],
+        ).start()
 
 
-async def cache_async_session(endpoint_uri: URI, session: ClientSession) -> None:
-    cache_key = generate_cache_key(endpoint_uri)
-    with _async_session_cache_lock:
-        evicted_items = _async_session_cache.cache(cache_key, session)
-        if evicted_items is not None:
-            for key, session in evicted_items.items():
-                await session.close()
+def _get_session(endpoint_uri: URI) -> requests.Session:
+    # cache key should have a unique thread identifier
+    cache_key = generate_cache_key(f"{threading.get_ident()}:{endpoint_uri}")
+    if cache_key not in _session_cache:
+        cache_session(endpoint_uri, requests.Session())
+    return _session_cache.get_cache_entry(cache_key)
 
 
-def _remove_session(key: str, session: requests.Session) -> None:
-    session.close()
+def make_post_request(
+    endpoint_uri: URI, data: bytes, *args: Any, **kwargs: Any
+) -> bytes:
+    kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
+    session = _get_session(endpoint_uri)
+    # https://github.com/python/mypy/issues/2582
+    response = session.post(endpoint_uri, data=data, *args, **kwargs)  # type: ignore
+    response.raise_for_status()
+
+    return response.content
 
 
-_session_cache = lru.LRU(8, callback=_remove_session)
+def _close_evicted_sessions(evicted_sessions: List[requests.Session]) -> None:
+    for evicted_session in evicted_sessions:
+        evicted_session.close()
+        logger.debug(f"Closed evicted session: {evicted_session}")
+
+
+# --- async --- #
+
+_async_session_cache = SessionCache(size=100)
 _async_session_cache_lock = threading.Lock()
-_async_session_cache = SessionCache(size=20)
 
 
-def _get_session(endpoint_uri: URI) -> requests.Session:
-    cache_key = generate_cache_key(endpoint_uri)
-    if cache_key not in _session_cache:
-        _session_cache[cache_key] = requests.Session()
-    return _session_cache[cache_key]
+async def cache_async_session(endpoint_uri: URI, session: ClientSession) -> None:
+    # cache key should have a unique thread identifier
+    cache_key = generate_cache_key(f"{threading.get_ident()}:{endpoint_uri}")
+
+    with _async_session_cache_lock:
+        evicted_items = _async_session_cache.cache(cache_key, session)
+        if evicted_items is not None:
+            for key, session in evicted_items.items():
+                await session.close()
 
 
 async def _get_async_session(endpoint_uri: URI) -> 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}")
+
     if cache_key not in _async_session_cache:
         await cache_async_session(endpoint_uri, ClientSession(raise_for_status=True))
     return _async_session_cache.get_cache_entry(cache_key)
 
 
-def make_post_request(endpoint_uri: URI, data: bytes, *args: Any, **kwargs: Any) -> bytes:
-    kwargs.setdefault('timeout', 10)
-    session = _get_session(endpoint_uri)
-    # https://github.com/python/mypy/issues/2582
-    response = session.post(endpoint_uri, data=data, *args, **kwargs)  # type: ignore
-    response.raise_for_status()
-
-    return response.content
-
-
 async def async_make_post_request(
     endpoint_uri: URI, data: bytes, *args: Any, **kwargs: Any
 ) -> bytes:
-    kwargs.setdefault('timeout', ClientTimeout(10))
+    kwargs.setdefault("timeout", ClientTimeout(DEFAULT_TIMEOUT))
     # https://github.com/ethereum/go-ethereum/issues/17069
     session = await _get_async_session(endpoint_uri)
-    async with session.post(endpoint_uri,
-                            data=data,
-                            *args,
-                            **kwargs) as response:
+    async with session.post(endpoint_uri, data=data, *args, **kwargs) as response:
         return await response.read()


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
@@ -1,8 +1,17 @@
+import asyncio
+from concurrent.futures import (
+    ThreadPoolExecutor,
+)
 import pytest
+import threading
+import time
 
 from aiohttp import (
     ClientSession,
 )
+from eth_typing import (
+    URI,
+)
 from requests import (
     Session,
     adapters,
@@ -15,6 +24,9 @@
 from web3._utils import (
     request,
 )
+from web3._utils.caching import (
+    generate_cache_key,
+)
 from web3._utils.request import (
     SessionCache,
 )
@@ -22,8 +34,8 @@
 
 class MockedResponse:
     def __init__(self, text="", status_code=200):
-        assert (isinstance(text, str))
-        assert (isinstance(status_code, int))
+        assert isinstance(text, str)
+        assert isinstance(status_code, int)
         self.status_code = status_code
         self.ok = 200 <= status_code < 400
         self.text = text
@@ -34,7 +46,14 @@ def raise_for_status(self):
         pass
 
 
-URI = "http://mynode.local:8545"
+TEST_URI = URI("http://mynode.local:8545")
+UNIQUE_URIS = [
+    "https://www.test1.com",
+    "https://www.test2.com",
+    "https://www.test3.com",
+    "https://www.test4.com",
+    "https://www.test5.com",
+]
 
 
 def check_adapters_mounted(session: Session):
@@ -42,25 +61,37 @@ def check_adapters_mounted(session: Session):
     assert len(session.adapters) == 2
 
 
+def _simulate_call(uri):
+    _session = request._get_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()
 
     # Submit a first request to create a session with default parameters
     assert len(request._session_cache) == 0
-    response = request.make_post_request(URI, b'request')
+    response = request.make_post_request(TEST_URI, data=b"request")
     assert response == "content"
     assert len(request._session_cache) == 1
-    session = request._session_cache.values()[0]
-    session.post.assert_called_once_with(URI, data=b'request', timeout=10)
+    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)
 
     # Ensure the adapter was created with default values
     check_adapters_mounted(session)
-    adapter = session.get_adapter(URI)
+    adapter = session.get_adapter(TEST_URI)
     assert isinstance(adapter, HTTPAdapter)
     assert adapter._pool_connections == DEFAULT_POOLSIZE
     assert adapter._pool_maxsize == DEFAULT_POOLSIZE
 
+    # clear cache
+    request._session_cache.clear()
+
 
 def test_precached_session(mocker):
     mocker.patch("requests.Session.post", return_value=MockedResponse())
@@ -68,46 +99,32 @@ def test_precached_session(mocker):
     # Update the cache with a handcrafted session
     adapter = adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100)
     session = Session()
-    session.mount('http://', adapter)
-    session.mount('https://', adapter)
-    request.cache_session(URI, session)
+    session.mount("http://", adapter)
+    session.mount("https://", adapter)
+    request.cache_session(TEST_URI, session)
 
     # Submit a second request with different arguments
     assert len(request._session_cache) == 1
-    response = request.make_post_request(URI, b'request', timeout=60)
+    response = request.make_post_request(TEST_URI, data=b"request", timeout=60)
     assert response == "content"
     assert len(request._session_cache) == 1
 
     # Ensure the timeout was passed to the request
-    session = request._get_session(URI)
-    session.post.assert_called_once_with(URI, data=b'request', timeout=60)
+    session = request._get_session(TEST_URI)
+    session.post.assert_called_once_with(TEST_URI, data=b"request", timeout=60)
 
     # Ensure the adapter parameters match those we specified
     check_adapters_mounted(session)
-    adapter = session.get_adapter(URI)
+    adapter = session.get_adapter(TEST_URI)
     assert isinstance(adapter, HTTPAdapter)
     assert adapter._pool_connections == 100
     assert adapter._pool_maxsize == 100
 
-
-@pytest.mark.asyncio
-async def test_async_precached_session(mocker):
-    # Add a session
-    session = ClientSession()
-    await request.cache_async_session(URI, session)
-    assert len(request._async_session_cache) == 1
-
-    # Make sure the session isn't duplicated
-    await request.cache_async_session(URI, session)
-    assert len(request._async_session_cache) == 1
-
-    # Make sure a request with a different URI adds another cached session
-    await request.cache_async_session(f"{URI}/test", session)
-    assert len(request._async_session_cache) == 2
+    # clear cache
+    request._session_cache.clear()
 
 
 def test_cache_session_class():
-
     cache = SessionCache(2)
     evicted_items = cache.cache("1", "Hello1")
     assert cache.get_cache_entry("1") == "Hello1"
@@ -126,9 +143,132 @@ def test_cache_session_class():
     evicted_items = cache.cache("3", "Hello3")
     assert "2" in cache
     assert "3" in cache
+
     assert "1" not in cache
+    assert "1" in evicted_items
 
     with pytest.raises(KeyError):
         # This should throw a KeyError since the cache size was 2 and 3 were inserted
         # the first inserted cached item was removed and returned in evicted items
         cache.get_cache_entry("1")
+
+    # clear cache
+    request._session_cache.clear()
+
+
+def test_cache_does_not_close_session_before_a_call_when_multithreading():
+    # save default values
+    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
+    request._session_cache = SessionCache(1)
+    _timeout_for_testing = 0.01
+    request.DEFAULT_TIMEOUT = _timeout_for_testing
+
+    with ThreadPoolExecutor(max_workers=len(UNIQUE_URIS)) as exc:
+        all_sessions = [exc.submit(_simulate_call, uri) for uri in UNIQUE_URIS]
+
+    # assert last session remains in cache, all others evicted
+    cache_data = request._session_cache._data
+    assert len(cache_data) == 1
+    _key, cached_session = cache_data.popitem()
+    assert cached_session == all_sessions[-1].result()  # result of the `Future`
+
+    # -- teardown -- #
+
+    # close the cached session before exiting test
+    cached_session.close()
+
+    # reset default values
+    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 -- #
+
+
+@pytest.mark.asyncio
+async def test_async_precached_session():
+    # Add a session
+    session = ClientSession()
+    await request.cache_async_session(TEST_URI, session)
+    assert len(request._async_session_cache) == 1
+
+    # Make sure the session isn't duplicated
+    await request.cache_async_session(TEST_URI, session)
+    assert len(request._async_session_cache) == 1
+
+    # Make sure a request with a different URI adds another cached session
+    await request.cache_async_session(URI(f"{TEST_URI}/test"), session)
+    assert len(request._async_session_cache) == 2
+
+    # -- teardown -- #
+
+    # appropriately close the cached sessions
+    [await session.close() for session in request._async_session_cache._data.values()]
+
+    # 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(
+            request._get_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()