Wiring AsyncClient into modern-di¶
If you wire your app's dependencies with modern-di and want connection-pool teardown and middleware composition to flow through the container's lifecycle, this is the bridge. Both libraries ship under the modern-python org.
The minimal wire-up¶
from modern_di import Container, Group, Scope, providers
from httpware import AsyncClient
class ServiceClients(Group):
api = providers.Factory(
scope=Scope.APP,
creator=AsyncClient,
kwargs={"base_url": "https://api.example.com"},
cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose),
)
async def main() -> None:
async with Container(scope=Scope.APP, groups=[ServiceClients]) as container:
client = await container.resolve(AsyncClient)
response = await client.get("/users/1")
print(response.status_code)
Breaking that down:
Scope.APPties the client to the application lifetime. One client per process; the connection pool is reused across all calls.cache_settings=providers.CacheSettings(...)is what makes the provider a singleton. Without it,Factoryreturns a freshAsyncClienton every resolve.finalizer=AsyncClient.acloseis the unbound async method.modern-didetects it as a coroutine function (viainspect.iscoroutinefunction) andawaits it on container teardown.
A common first instinct here is finalizer=lambda c: c.aclose(). That does not work — the lambda itself is sync, so modern-di calls it synchronously and discards the returned coroutine unawaited. The underlying connection pool leaks. Pass the unbound async method directly, or wrap in async def.
See the modern-di factories docs for the broader CacheSettings story (scopes, clear_cache, sync vs async finalizers).
Adding a second backend hits a type collision¶
The obvious move when you talk to a second backend — register another Factory(creator=AsyncClient, ...) — fails at container construction:
class ServiceClients(Group):
user_api = providers.Factory(
scope=Scope.APP,
creator=AsyncClient,
kwargs={"base_url": "https://users.example.com"},
cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose),
)
billing_api = providers.Factory(
scope=Scope.APP,
creator=AsyncClient,
kwargs={"base_url": "https://billing.example.com"},
cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose),
)
# At Container(...) construction:
# modern_di.exceptions.DuplicateProviderTypeError: Provider is duplicated by type
# <class 'httpware.client.AsyncClient'>. To resolve this issue: ...
modern-di resolves dependencies by bound_type, which defaults to the creator's return type. Both providers default to bound_type=AsyncClient and collide in the providers registry.
Fix: one wrapper subclass per backend¶
Give each provider a distinct bound_type by subclassing AsyncClient:
from modern_di import Container, Group, Scope, providers
from httpware import AsyncClient
class UserApi(AsyncClient):
"""Typing handle for the User service backend."""
class BillingApi(AsyncClient):
"""Typing handle for the Billing service backend."""
class ServiceClients(Group):
user_api = providers.Factory(
scope=Scope.APP,
creator=UserApi,
kwargs={"base_url": "https://users.example.com"},
cache_settings=providers.CacheSettings(finalizer=UserApi.aclose),
)
billing_api = providers.Factory(
scope=Scope.APP,
creator=BillingApi,
kwargs={"base_url": "https://billing.example.com"},
cache_settings=providers.CacheSettings(finalizer=BillingApi.aclose),
)
async def main() -> None:
async with Container(scope=Scope.APP, groups=[ServiceClients]) as container:
users = await container.resolve(UserApi)
billing = await container.resolve(BillingApi)
# ... use them
A couple of notes:
- Subclasses are typing-only. Empty body, no overrides. They inherit
__init__,aclose, and every HTTP method unchanged. - Each
Factorynow has a distinctbound_type, socontainer.resolve(UserApi)andcontainer.resolve(BillingApi)route to the right provider. modern-di's error suggestions are subclass-aware. If a caller asks forcontainer.resolve(AsyncClient)after only the subclasses are registered, the error message points them at the right subclass.
Middleware in kwargs=¶
AsyncClient's middleware chain is composed once at construction and frozen for the client's lifetime. With a singleton-scoped Factory, "once at construction" means "once per container build." Drop the middleware list into kwargs=:
from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
class ServiceClients(Group):
user_api = providers.Factory(
scope=Scope.APP,
creator=UserApi,
kwargs={
"base_url": "https://users.example.com",
"middleware": [AsyncBulkhead(max_concurrent=10), AsyncRetry()],
},
cache_settings=providers.CacheSettings(finalizer=UserApi.aclose),
)
Each cached singleton owns its own AsyncBulkhead and AsyncRetry state — what you want when different backends have different reliability profiles.
See also¶
- Quick-Start — the base
AsyncClientAPI. - Middleware guide — what
AsyncBulkheadandAsyncRetryare doing inkwargs[middleware]. - Resilience reference — every parameter on
AsyncRetry,RetryBudget,AsyncBulkhead. modern-difactories —CacheSettings, scopes, the broader provider story.