Skip to content

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.APP ties 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, Factory returns a fresh AsyncClient on every resolve.
  • finalizer=AsyncClient.aclose is the unbound async method. modern-di detects it as a coroutine function (via inspect.iscoroutinefunction) and awaits 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 Factory now has a distinct bound_type, so container.resolve(UserApi) and container.resolve(BillingApi) route to the right provider.
  • modern-di's error suggestions are subclass-aware. If a caller asks for container.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