tortoise / tortoise-orm

Familiar asyncio ORM for python, built with relations in mind
https://tortoise.github.io
Apache License 2.0
4.68k stars 392 forks source link

Tortoise crashes when used with Vercel and FastAPI #1684

Open Thytu opened 3 months ago

Thytu commented 3 months ago

Describe the bug Tortoise seems to crash when used with Vercel (and probably any serverless service) + FastAPI.

When using tortoise-orm with FastAPI and Vercel, tortoise cannot manage to fetch the database connection resulting in the following error:

TypeError: 'NoneType' object is not iterable
Traceback (most recent call last):
  File "/var/task/vc__handler__python.py", line 315, in vc_handler
    response = asgi_cycle(__vc_module.app, body)
  File "/var/task/vc__handler__python.py", line 215, in __call__
    asyncio.run(self.run_asgi_instance(asgi_instance))
  File "/var/lang/lib/python3.12/asyncio/runners.py", line 194, in run
    return runner.run(main)
  File "/var/lang/lib/python3.12/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
  File "/var/lang/lib/python3.12/asyncio/base_events.py", line 687, in run_until_complete
    return future.result()
  File "/var/task/vc__handler__python.py", line 219, in run_asgi_instance
    await asgi_instance
  File "/var/task/fastapi/applications.py", line 1054, in __call__
    await super().__call__(scope, receive, send)
  File "/var/task/starlette/applications.py", line 123, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/var/task/starlette/middleware/errors.py", line 186, in __call__
    raise exc
  File "/var/task/starlette/middleware/errors.py", line 164, in __call__
    await self.app(scope, receive, _send)
  File "/var/task/starlette/middleware/cors.py", line 85, in __call__
    await self.app(scope, receive, send)
  File "/var/task/starlette/middleware/exceptions.py", line 65, in __call__
    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
  File "/var/task/starlette/_exception_handler.py", line 64, in wrapped_app
    raise exc
  File "/var/task/starlette/_exception_handler.py", line 53, in wrapped_app
    await app(scope, receive, sender)
  File "/var/task/starlette/routing.py", line 756, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/var/task/starlette/routing.py", line 776, in app
    await route.handle(scope, receive, send)
  File "/var/task/starlette/routing.py", line 297, in handle
    await self.app(scope, receive, send)
  File "/var/task/starlette/routing.py", line 77, in app
    await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  File "/var/task/starlette/_exception_handler.py", line 64, in wrapped_app
    raise exc
  File "/var/task/starlette/_exception_handler.py", line 53, in wrapped_app
    await app(scope, receive, sender)
  File "/var/task/starlette/routing.py", line 72, in app
    response = await func(request)
  File "/var/task/fastapi/routing.py", line 278, in app
    raw_response = await run_endpoint_function(
  File "/var/task/fastapi/routing.py", line 191, in run_endpoint_function
    return await dependant.call(**values)
  File "/var/task/Manager/main.py", line 48, in some_example
    await Video.all() # KO
  File "/var/task/tortoise/models.py", line 1262, in all
    return cls._db_queryset(using_db)
  File "/var/task/tortoise/models.py", line 1063, in _db_queryset
    db = using_db or cls._choose_db(for_write)
  File "/var/task/tortoise/models.py", line 1011, in _choose_db
    db = router.db_for_read(cls)
  File "/var/task/tortoise/router.py", line 36, in db_for_read
    return self._db_route(model, "db_for_read")
  File "/var/task/tortoise/router.py", line 31, in _db_route
    return connections.get(self._router_func(model, action))
  File "/var/task/tortoise/router.py", line 18, in _router_func
    for r in self._routers:

To Reproduce Here is the code-snippet to reproduce it:

# app/main.py
from fastapi import FastAPI
from tortoise import Tortoise
from contextlib import asynccontextmanager
from tortoise.contrib.fastapi import RegisterTortoise
from tortoise import models, fields

# NOTE: this must be defined in
# a different file and then imported
class SomeTable(models.Model):
    id = fields.IntField(pk=True)

@asynccontextmanager
async def lifespan(app: FastAPI):
    async with RegisterTortoise(
        app=app,
        config=YOUR_CONFIG_HERE,
        generate_schemas=True,
        add_exception_handlers=True,
    ):
        yield

    await Tortoise.close_connections()

app = FastAPI(lifespan=lifespan)

@app.get("/some-example")
async def some_example():
    await SomeTable.all() # TypeError: 'NoneType' object is not iterable
    return "some expected response"

if __name__ == "__main__":
    import uvicorn

    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

Obviously, in order to reproduce the error one also needs to deploy it on Vercel :)

Expected behavior I would expect tortoise to use previously initialized connection to return every elements of SomeTable.

Additional context Note that in order to debug I also tried the following, which also results in a crash. However this time it crashs at the second call to the database as it tried to close an event loop that no-longer exists, resulting in a RuntimeError('Event loop is closed') error.

# app/main.py
from fastapi import FastAPI
from tortoise import Tortoise
from contextlib import asynccontextmanager
from tortoise.contrib.fastapi import RegisterTortoise
from tortoise import models, fields

# NOTE: this must be defined in
# a different file and then imported
class SomeTable(models.Model):
    id = fields.IntField(pk=True)

async def init_db():
    await Tortoise.init(config=YOUR_CONFIG_HERE)
    await Tortoise.generate_schemas()

app = FastAPI()

@app.get("/some-example")
async def some_example():
    await init_db() # First call works
    # second calls fail with `raise RuntimeError('Event loop is closed')``
    await SomeTable.all()
    return "some expected response"

if __name__ == "__main__":
    import uvicorn

    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
Thytu commented 3 months ago

Update: I found this temporary fix, however I'll obviously need to find a better option :D

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    await init_db() # already in the previous snippet

    response = await call_next(request)

    await Tortoise.close_connections() # Now also close the session after each call

    return response