From edfbc4645b6604cb6e33159a2c33b26b536ac071 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 17:03:50 +0300 Subject: [PATCH] fix: reopen root container across lifespan cycles The lifespan manager closed the root container at shutdown but never reopened it, so a second lifespan cycle against the same container (repeated test lifespans, server restarts) raised ContainerClosedError on the first request. Use `async with container:` so __aenter__ reopens on startup and __aexit__ closes on shutdown. Requires modern-di>=2.19.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- modern_di_fastapi/main.py | 8 ++++---- pyproject.toml | 2 +- tests/test_lifespan.py | 25 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 tests/test_lifespan.py diff --git a/modern_di_fastapi/main.py b/modern_di_fastapi/main.py index c044d7f..c1415c9 100644 --- a/modern_di_fastapi/main.py +++ b/modern_di_fastapi/main.py @@ -21,11 +21,11 @@ def fetch_di_container(app_: fastapi.FastAPI) -> Container: @contextlib.asynccontextmanager async def _lifespan_manager(app_: fastapi.FastAPI) -> typing.AsyncIterator[None]: - container = fetch_di_container(app_) - try: + # ``async with`` reopens the root container on each startup (``__aenter__``) + # and closes it on shutdown, so a second lifespan cycle against the same + # container works instead of raising ContainerClosedError. + async with fetch_di_container(app_): yield - finally: - await container.close_async() def setup_di(app: fastapi.FastAPI, container: Container) -> Container: diff --git a/pyproject.toml b/pyproject.toml index 1706ab9..d578924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Typing :: Typed", "Topic :: Software Development :: Libraries", ] -dependencies = ["fastapi>=0.100,<1", "modern-di>=2.16.1,<3"] +dependencies = ["fastapi>=0.100,<1", "modern-di>=2.19.0,<3"] version = "0" [project.urls] diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py new file mode 100644 index 0000000..4080470 --- /dev/null +++ b/tests/test_lifespan.py @@ -0,0 +1,25 @@ +import typing + +import fastapi +from starlette import status +from starlette.testclient import TestClient + +from modern_di_fastapi import FromDI, fetch_di_container +from tests.dependencies import Dependencies, SimpleCreator + + +def test_lifespan_reopens_container_across_cycles(app: fastapi.FastAPI) -> None: + @app.get("/") + async def read_root(instance: typing.Annotated[SimpleCreator, FromDI(Dependencies.app_factory)]) -> None: + assert isinstance(instance, SimpleCreator) + + container = fetch_di_container(app) + + # First lifespan cycle: shutdown closes the root container. + with TestClient(app=app) as client: + assert client.get("/").status_code == status.HTTP_200_OK + assert container.closed + + # Second cycle must reopen the same container instead of raising ContainerClosedError. + with TestClient(app=app) as client: + assert client.get("/").status_code == status.HTTP_200_OK