starlette: RunTimeError: got Future attached to a different loop when using custom loop in sync fixtures when upgrading from 0.14.2 to 0.15.0
Checklist
- The bug is reproducible against the latest release and/or
master. - There are no similar issues or pull requests to fix it yet.
Describe the bug
Upgrading starlette>=0.15.0 breaks current testing strategy. The setup is mocking a nats subscription by actually using the nats server.
The code works with starlette 0.14.2, upgradign to 0.15.0 gives RunTumeError got Future <Future pending> attached to a different loop . When upgrading to starlette 0.16.0 it gives TimeOut errors. I would love to keep tests sync.
To reproduce
requirements.txt
starlette
requests
pytest
asyncio-nats-client
code
from starlette.routing import Route
from starlette.testclient import TestClient
import pytest
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
import asyncio
from nats.aio.client import Client as NATS
"""
Test work with starlette 0.14.2
Error with starlette 0.15.0: RunTimeError: got Future <Future pending> attached to a different loop
Error with starlette 0.16.0: Nats timeout
The test is that the client code makes a nats request to a mocked nats service over nats itself.
Requirement a running nats server `docker run -d -p 4222:4222 nats:latest`
"""
HOST_NATS = "localhost:4222"
# =======================================================================
# CODE
# =======================================================================
def create_app():
async def index(request):
r = await request.app.state.nc.request("subject1", timeout=1, payload=b"PING")
return PlainTextResponse(content=r.data.decode())
async def setup() -> None:
await app.state.nc.connect(HOST_NATS)
print("Connected to nats")
app = Starlette(debug=True, routes=[Route('/', index)], on_startup=[setup])
app.state.nc: NATS = NATS()
return app
app = create_app()
# =======================================================================
# MOCKS & TESTS
# =======================================================================
class NatsServiceMock:
def __init__(self) -> None:
self.nc: NATS = NATS()
async def lifespan(self) -> None:
await self.nc.connect(HOST_NATS)
await self.nc.subscribe("subject1", cb=self.handle)
async def handle(self, msg):
await self.nc.publish(msg.reply, b"PONG")
def __enter__(self):
loop = asyncio.get_event_loop()
loop.run_until_complete(self.lifespan())
return self
def __exit__(self, *args) -> None:
pass
@pytest.fixture(scope="session")
def nats_service() -> None:
with NatsServiceMock() as nc:
yield nc
@pytest.fixture(scope="session")
def test_app(nats_service) -> None:
with TestClient(create_app()) as client:
yield client
# =======================================================================
# TESTS
# =======================================================================
def test_index_should_give_a_succesful_response(test_app):
r = test_app.get("/")
assert r.status_code == 200
assert r.text == "PONG"
Run:
pytest <file>
Expected behavior
The test to work.
Actual behavior
Test does not work.
Debugging material
output running with starlette 0.15.0:
try:
# wait until the future completes or the timeout
try:
> await waiter
E RuntimeError: Task <Task pending name='anyio.from_thread.BlockingPortal._call_func' coro=<BlockingPortal._call_func() running at /home/sevaho/.local/share/virtualenvs/testje-KTUsWEz0/lib/python3.8/site-packages/anyio/from_thread.py:177> cb=[TaskGroup._spawn.<locals>.task_done() at /home/sevaho/.local/share/virtualenvs/testje-KTUsWEz0/lib/python3.8/site-packages/anyio/_backends/_asyncio.py:622]> got Future <Future pending> attached to a different loop
/usr/lib/python3.8/asyncio/tasks.py:481: RuntimeError
output when running with starlette 0.16.0:
# Wait for the response or give up on timeout.
try:
msg = await asyncio.wait_for(future, timeout, loop=self._loop)
return msg
except asyncio.TimeoutError:
del self._resp_map[token.decode()]
future.cancel()
> raise ErrTimeout
E nats.aio.errors.ErrTimeout: nats: Timeout
/home/sevaho/.local/share/virtualenvs/testje-KTUsWEz0/lib/python3.8/site-packages/nats/aio/client.py:945: ErrTimeout
Environment
- OS: Linux
- Python version: 3.8
- Starlette version: 0.14.2 / 0.15.0 / 0.16.0
Additional context
<picture> <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/encode/starlette/issues/1315/pledge.svg?darkmode=1">[!IMPORTANT]
- We’re using Polar.sh so you can upvote and help fund this issue.
- We receive the funding once the issue is completed & confirmed by you.
- Thank you in advance for helping prioritize & fund our backlog.
About this issue
- Original URL
- State: open
- Created 3 years ago
- Reactions: 25
- Comments: 28 (8 by maintainers)
The latter calls the startup event, while the former doesn’t.
For testing FastAPI with SQLAlchemy, one option is overriding the engine to use a NullPool (https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#using-multiple-asyncio-event-loops).
I have faced the same issues in my unit tests. I fixed the issue by using a context manager for
TestClient. I’ve no idea why it works though (especially because it is not used as context manager in the docs of FastAPI) 😞Doesn’t work:
Works:
I’ve got similar issue in tests with FastAPI (0.68.2 --> 0.69.0 which means Starlette upgrading from 0.14.2 to 0.15.0) and databases library (based on asyncpg):
With FastAPI 0.68.2 (Starlette 0.14.2) works like a charm.
My solution for async SQLAlchemy + FastApi + Pytest:
in ‘main.py’
in db file
and client object in “test.py”
Also faced with this problem. It seems to me that the problem is that the reference to the database is in a separate event loop, which closes before the test receives data.
Described part of my project:
main.py
apy/api.py
api/api_v1/endpoints/employee.py
crud/employee.py
database/database.py
test
Same issue. Had to address by pinning Fastapi to
0.68.2I am seeing similar issues, where I have my db_session fixture that ensures unittest changes are rolled back after every test, this requires the app to use the same session to be able to see the data loaded into the database.
With the change to anyio, I am yet to find a combination of things that will allow me to have the fixture db_session on the same loop as the TestClient.
The following worked prior to anyio.
server.py
tests/conftest.py
tests/views/conftest.py
This allowed me to populate the database, then make a request on the TestClient and see the same data.
If there was a way to access the loop that TestClient started up, I could create a new db_session for the client as asyncpg will accept a loop parameter, allowing me to run them on the same loop (hopefully). As long as the TestClient doesn’t spawn new loops on requests.
I have the same issue about this with using SQLAlchemy async connect I change the code from
to
to fix the problem, but obviously not a solution
Still actual in 2023, if you encountered with the same issue, here is workaround that helped me:
first: set up asgi-lifespan https://pypi.org/project/asgi-lifespan/
If you have any connection initialization actions in fixtures with session level make sure you override event_loop fixture.
otherwise you will receive ScopeMismatch error.
Startup your test client with LifespanManager:
Here’s a very minimal reproduction:
when running this under
pytest, you will see two lines printed with event-loop ids. My expectation is that the IDs would be the same, but they are not.This makes testing impossible for my use-case – create a
Taskin a “request context” (when a request comes in,create_taskand kick it to the event loop for scheduling); thenasyncio.wait_forit during tests, to assert the result of execution.@sevaho Can you test if this version works without the error?
@aminalaee No, I don’t think it’s related to
anyioat all; I believe the problem is related to a change in behavior caused by Starlette’s switch to anyio.Basically, I could be wrong but my initial understanding is:
loop = asyncio.get_event_loop()inTestClientwhich would use a default event loop if one didn’t exist or use an existing one if it didanyio.start_blocking_portalto get the event loop which means it will always create a new event loopThis means that if you create any objects that bind to the default event loop before calling
TestClient.__enter__, you will probably get strange asyncio errors.Does this sound like an accurate description of the situation? Asking because this is how I understand it, but I’m not 100% sure I understand
anyio.start_blocking_portalbecause from the docs it doesn’t explicitly indicate that it creates a new event loop.I’m not using Mongo so for now my only solution was to downgrade and pin fastapi to
0.68.2@Hazzari have you tried setting the loop on Motor client?
How to reproduce with
asyncio.LockandabI get the same issue with FastAPI (based on starlette) and mongo motor client. Just downgraded to 14.2 and fixed the problem as well.