Async resources via lifespan¶
Problem. A resource genuinely needs an await (or a running event loop) to construct — aiohttp.ClientSession, an asyncpg connection pool, an authenticated client whose construction does a token exchange. modern-di resolves synchronously, so the construction has to happen outside the resolve path.
Solution¶
Do the async construction in the framework's lifespan. Use container.set_context(SomeType, instance) to register the live object on the APP container, then declare a ContextProvider(scope=Scope.APP, context_type=SomeType) so downstream factories can depend on the type.
import contextlib
from collections.abc import AsyncIterator
import aiohttp
import fastapi
from modern_di import Container, Group, Scope, providers
class Dependencies(Group):
http_client = providers.ContextProvider(
scope=Scope.APP,
context_type=aiohttp.ClientSession,
)
# Downstream factories declare `client: aiohttp.ClientSession` and get the live instance
weather_api = providers.Factory(
scope=Scope.REQUEST,
creator=WeatherApi, # signature: (client: aiohttp.ClientSession)
)
container = Container(groups=[Dependencies], validate=True)
@contextlib.asynccontextmanager
async def lifespan(app: fastapi.FastAPI) -> AsyncIterator[None]:
async with container: # ensures close_async on exit
async with aiohttp.ClientSession() as session: # must be inside running loop
container.set_context(aiohttp.ClientSession, session)
yield
# ClientSession is closed by `async with` here
app = fastapi.FastAPI(lifespan=lifespan)
aiohttp.ClientSession captures the running event loop at construction time, so it has to be built inside an async context — which the lifespan provides.
The same pattern works for asyncpg.create_pool(...) (truly async), authenticated API clients that do a token exchange at startup, or anything else that needs await to be ready.
Pitfalls¶
- Set context before yielding. The lifespan hands control to the app inside the
yield. If youset_contextafter yielding, requests that arrive in between won't see the value. set_contextnever propagates between containers. AContextProviderreads the context registry of the container at its own scope, soset_contexton the parent never reaches a child-scoped provider (regardless of build order). In the lifespan pattern above this is fine — the resource is APP-scoped, so the APP-scopedContextProviderreads the value set on the APP container; per-request context is passed to each REQUEST child viabuild_child_container(context={...}).- Choose APP scope unless the resource is per-connection. Redis/Kafka clients are process-singletons. For per-websocket-session resources, use
Scope.SESSION. async with container:handles APP-scope finalizers. If you also registered aCacheSettings(finalizer=...)somewhere, this runs it on exit. The lifespan-managed object isn't wrapped by a Factory, so its cleanup (async with aiohttp.ClientSession()in the example) is on you.
When a sync creator works instead¶
Many "async" resources actually construct synchronously — redis.asyncio.Redis.from_url(...), sqlalchemy.ext.asyncio.create_async_engine(...), and httpx.AsyncClient(...) all return without awaiting. For those, prefer a normal Factory with cache_settings=CacheSettings(finalizer=async_close_fn) and skip the lifespan + set_context dance entirely. Use this recipe only when construction genuinely needs await or a running event loop.
See also¶
- Lifecycle —
close_async()and finalizers. - Context Provider —
ContextProviderandset_contextin depth. - Scopes — APP vs SESSION vs REQUEST.
- Async SQLAlchemy recipe — the sync-creator-with-async-finalizer pattern for comparison.