Repository: ethereum/web3.py

Issue:
Async request session caching incompatible with Flask
Flask is not natively async, so it creates a new thread loop for every async request.

When async web3py does an aiohttp call, it caches the session. So the next time you make a request it pulls the stale session out of the cache and throws an "Event loop is closed" exception.

To fix the issue we made the following code changes in request.py as a hack.

```
async def get_async_session(endpoint_uri: URI) -> ClientSession:
    cache_key = generate_cache_key(endpoint_uri)
    ########### TEMP CODE ###########
    if True:# cache_key not in _async_session_cache: # no caching for you!
    ########### TEMP CODE ###########
        await cache_async_session(endpoint_uri, ClientSession(raise_for_status=True))
    return _async_session_cache.get_cache_entry(cache_key)


async def async_get_response_from_get_request(
    endpoint_uri: URI, *args: Any, **kwargs: Any
) -> ClientResponse:
    kwargs.setdefault("timeout", ClientTimeout(10))
    session = await get_async_session(endpoint_uri)
    response = await session.get(endpoint_uri, *args, **kwargs)
    ########### TEMP CODE ###########
    await session.close()
    ########### TEMP CODE ###########
    return response


async def async_get_response_from_post_request(
    endpoint_uri: URI, *args: Any, **kwargs: Any
) -> ClientResponse:
    kwargs.setdefault("timeout", ClientTimeout(10))
    session = await get_async_session(endpoint_uri)
    response = await session.post(endpoint_uri, *args, **kwargs)
    ########### TEMP CODE ###########
    await session.close()
    ########### TEMP CODE ###########
    return response
```

Would be nice to have a setting to disable session caching.

---
**Note:** We prefer to use issues to track our work. If you think you've encountered a bug in web3py or 
have a feature request, you're in the right place. If you have implementation or usage questions, 
please refer to our [documentation](https://web3py.readthedocs.io/en/latest/) and/or join the conversation 
on [discord](https://discord.gg/GHryRvPB84).



Discussion:
Hey @GadzeMike, thanks for submitting. Yeah, I think I'm good with making caching optional. Anything that steers the repo towards more customisability is a plus in my book if it makes sense. I think another way you could get around it is to clear the cache after each call on your end for now. We are working on revamping async cache atm as there are some race conditions when multithreading... check the PR [here](https://github.com/ethereum/web3.py/pull/2409) and make sure that the problem still exists on your end. I believe if Flask works the way you described it that the PR I linked won't fix that but if you want to give it a shot then I figured I'd link that for now.

We'll try to address the optional caching soon. In the meantime if you want to submit a PR they are always welcome :)
In wonder if another option is to check if the session is stale somehow? and then invalidate the cache if it is. I fiddled with it but couldn't get it working.

I'll try out the PR, thanks! 
@GadzeMike any luck with that PR? All those changes are currently in the `master` branch, along with some other updates to the caching. If you can try the current `master` branch and update that would be great. We should also have a new version with these recent changes out soon.

If this is still an obstacle, I think caching sessions being optional would take a bit more priority than just a nice-to-have. Update here whenever you can. 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
@@ -142,6 +142,39 @@ async def cache_and_return_async_session(
             evicted_items = _async_session_cache.cache(cache_key, session)
             logger.debug(f"Async session cached: {endpoint_uri}, {session}")
 
+        else:
+            # get the cached session
+            cached_session = _async_session_cache.get_cache_entry(cache_key)
+            session_is_closed = cached_session.closed
+            session_loop_is_closed = cached_session._loop.is_closed()
+
+            warning = (
+                "Async session was closed"
+                if session_is_closed
+                else "Loop was closed for async session"
+                if session_loop_is_closed
+                else None
+            )
+            if warning:
+                logger.debug(
+                    f"{warning}: {endpoint_uri}, {cached_session}. "
+                    f"Creating and caching a new async session for uri."
+                )
+
+                _async_session_cache._data.pop(cache_key)
+                if not session_is_closed:
+                    # if loop was closed but not the session, close the session
+                    await cached_session.close()
+                logger.debug(
+                    f"Async session closed and evicted from cache: {cached_session}"
+                )
+
+                # replace stale session with a new session at the cache key
+                _session = ClientSession(raise_for_status=True)
+                evicted_items = _async_session_cache.cache(cache_key, _session)
+                logger.debug(f"Async session cached: {endpoint_uri}, {_session}")
+
+        # get the cached session
         cached_session = _async_session_cache.get_cache_entry(cache_key)
 
     if evicted_items is not None:


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
@@ -396,3 +396,73 @@ def target_function(endpoint_uri):
 
     # appropriately close the test sessions
     [await session.close() for session in test_sessions]
+
+
+@pytest.mark.asyncio
+async def test_async_use_new_session_if_loop_closed_for_cached_session():
+    # create new loop, cache a session wihin the loop, close the loop
+    loop1 = asyncio.new_event_loop()
+
+    session1 = ClientSession(raise_for_status=True)
+    session1._loop = loop1
+
+    await cache_and_return_async_session(TEST_URI, session=session1)
+
+    # assert session1 was cached
+    cache_key = generate_cache_key(f"{threading.get_ident()}:{TEST_URI}")
+
+    assert len(request._async_session_cache) == 1
+    cached_session = request._async_session_cache.get_cache_entry(cache_key)
+    assert cached_session == session1
+
+    # close loop that was used with session1
+    loop1.close()
+
+    # assert we create a new session when trying to retrieve the session at the
+    # cache key for TEST_URI
+    session2 = await cache_and_return_async_session(TEST_URI)
+    assert not session2._loop.is_closed()
+    assert session2 != session1
+
+    # assert we appropriately closed session1, evicted it from the cache, and cached
+    # the new session2 at the cache key
+    assert session1.closed
+    assert len(request._async_session_cache) == 1
+    cached_session = request._async_session_cache.get_cache_entry(cache_key)
+    assert cached_session == session2
+
+    # -- teardown -- #
+
+    # appropriately close the new session
+    await session2.close()
+
+
+@pytest.mark.asyncio
+async def test_async_use_new_session_if_session_closed_for_cached_session():
+    # create a session, close it, and cache it at the cache key for TEST_URI
+    session1 = ClientSession(raise_for_status=True)
+    await session1.close()
+    await cache_and_return_async_session(TEST_URI, session=session1)
+
+    # assert session1 was cached
+    cache_key = generate_cache_key(f"{threading.get_ident()}:{TEST_URI}")
+
+    assert len(request._async_session_cache) == 1
+    cached_session = request._async_session_cache.get_cache_entry(cache_key)
+    assert cached_session == session1
+
+    # assert we create a new session when trying to retrieve closed session from cache
+    session2 = await cache_and_return_async_session(TEST_URI)
+    assert not session2.closed
+    assert session2 != session1
+
+    # assert we evicted session1 from the cache, and cached the new session2
+    # at the cache key
+    assert len(request._async_session_cache) == 1
+    cached_session = request._async_session_cache.get_cache_entry(cache_key)
+    assert cached_session == session2
+
+    # -- teardown -- #
+
+    # appropriately close the new session
+    await session2.close()