Skip to content

Dependency injection

The pool is application-scoped, the factory too, and clients are cheap enough to be request-scoped. What a container has to get right is the lifecycle at the two ends: start the health checker once the factory is resolved, and close the pool when the container closes. grpc_client_kit.dishka (the dishka extra) ships that as providers, the way grpc_server_kit.aio.dishka does for the server.

The providers

from dishka import make_async_container

from grpc_client_kit import GrpcClientFactory
from grpc_client_kit.dishka import grpc_client_providers

container = make_async_container(
    *grpc_client_providers(settings.users_grpc),
    MyDomainProvider(),
)

factory = await container.get(GrpcClientFactory)  # enters the factory: starts the checker
users = factory.create_client(UserStub)
...
await container.close()  # leaves it: stops the checker, closes the pool

grpc_client_providers(settings, *, component=None, metrics_prefix=None, shutdown_grace=5.0, ready_timeout=10.0, shared_pool=False) returns the first three providers below, which can also be registered one by one; the fourth is registered on its own, by the containers whose upstreams share one pool:

Provider Provides Notes
GrpcClientSettingsProvider(settings) GrpcClientSettingsProtocol Holds the settings object: BaseGrpcClientSettings or anything structural
PrometheusGrpcClientMetricsProvider(prefix=None) GrpcClientMetricsProtocol \| None The shipped collector when settings.metrics_enabled, None otherwise
AsyncGrpcClientProvider(shutdown_grace=5.0, ready_timeout=10.0, shared_pool=False) GrpcClientFactory APP scope, from an async generator: entered on first resolution, left when the container closes; shared_pool=True borrows the default component's ChannelPool instead of building one
AsyncChannelPoolProvider(settings=None, metrics=None, shutdown_grace=5.0) ChannelPool APP scope, from an async generator: the pool several upstreams share, sized by a ChannelPoolSettingsProtocol, drained with shutdown_grace when the container closes

The factory provider requests the settings and the registry through their protocols rather than taking them in its constructor, so a settings object provided by your own provider works just as well as GrpcClientSettingsProvider, and a registry of your own replaces the Prometheus one by providing GrpcClientMetricsProtocol | None instead. What is not done for you is defaulting the registry: a container with AsyncGrpcClientProvider and no provider of GrpcClientMetricsProtocol | None is refused when it is built, which is the moment to find that out.

All of it is async with GrpcClientFactory(...) in the container's terms. Resolving GrpcClientFactory enters the factory — with a health_checker block that starts the probes and waits up to ready_timeout for the first pass, for the reason the factory does — and closing the container leaves it, giving in-flight RPCs shutdown_grace before the pool's channels are closed. Clients are created from the resolved factory with create_client exactly as without a container; a per-request client is fine, since recreating one is cheap.

Metrics

PrometheusGrpcClientMetricsProvider hands out get_grpc_client_metrics(prefix): one collector per prefix on the process-wide registry, whatever the number of containers. That is what keeps a test suite that builds a container per test from hitting Prometheus's "duplicated timeseries" error on the second one — the collector is registered once and handed back afterwards. When settings.metrics_enabled is off the provider yields None, and the factory leaves the metrics layer out; when it is on but the metrics extra is absent, it yields None and logs a warning, so the provider can always be registered.

Starting the /metrics HTTP server is the application's job, as with the server kit: prometheus_client.start_http_server(port) once at startup, not in a provider.

Several upstreams

One upstream, one settings object, one factory: that is what the default component gives you. A service with several clients registers one bundle per upstream, each in its own Dishka component:

from typing import Annotated

from dishka import FromComponent, make_async_container

container = make_async_container(
    *grpc_client_providers(settings.users_grpc, component="users"),
    *grpc_client_providers(settings.orders_grpc, component="orders"),
)

users = await container.get(GrpcClientFactory, component="users")
orders = await container.get(GrpcClientFactory, component="orders")


class Checkout:
    def __init__(self, orders: Annotated[GrpcClientFactory, FromComponent("orders")]) -> None: ...

Dishka resolves a component's dependencies inside that component, which is why every provider takes component= and the bundle registers all three there: the users factory reads the users settings and nothing else. The collector is the exception by design — it is cached per prefix, so both components hand out the same instance, and the two upstreams' series are told apart by the service label rather than by separate metrics.

One pool for several upstreams

Registered that way, each component's factory builds a pool of its own, as it does outside a container. Upstreams whose retry and timeout policy differ but whose channels should sit in one pool — one set of pool statistics, one section configuring it — register the pool once, in the default component, and tell each bundle to borrow it:

from grpc_client_kit.dishka import AsyncChannelPoolProvider, grpc_client_providers
from grpc_client_kit.metrics import get_grpc_client_metrics

container = make_async_container(
    AsyncChannelPoolProvider(settings.grpc_pool, metrics=get_grpc_client_metrics()),
    *grpc_client_providers(settings.users_grpc, component="users", shared_pool=True),
    *grpc_client_providers(settings.orders_grpc, component="orders", shared_pool=True),
)

pool = await container.get(ChannelPool)  # the one both factories draw from

AsyncChannelPoolProvider provides ChannelPool in APP scope the way the factory provider provides the factory: built on first resolution, drained with close_all(grace=shutdown_grace) when the container closes. It is sized by a ChannelPoolSettingsProtocol — a BaseChannelPoolSettings nested at the top of the service's settings is one deployment-wide section, GRPC_POOL__MAX_CHANNELS_PER_TARGET and GRPC_POOL__IDLE_TIMEOUT, next to the per-upstream ones — and is at the pool's own defaults without one. metrics= is the registry its statistics are reported into, handed in rather than requested: a container with one component per upstream has a registry per component and none in the default one, so there is no single seam for the pool to ask; get_grpc_client_metrics() is the instance the bundles hand out.

A factory with shared_pool=True resolves ChannelPool from the default component (Annotated[ChannelPool, FromComponent("")], so anything providing ChannelPool there will do) and is GrpcClientFactory(pool=...): it borrows the pool and never closes it, and owns nothing but its health checker. The container closes generators in reverse order of entry, so every factory leaves — stopping its checker — before the pool is drained, and the pool is drained exactly once. The bundle's shutdown_grace then has nothing to apply to; the pool provider's is the one in-flight RPCs get. The upstream's own pool block is not read either: the pool was built before the factory asked.

Three things stay per upstream. The settings and the policy they carry, which is the point. The health checker, because it probes that upstream's targets; two health-checked upstreams sharing a pool are two checkers, each marking its own addresses in the one pool. And the choice: a bundle registered without shared_pool next to shared ones builds and owns its own pool as before. A shared_pool=True bundle in a container with no ChannelPool in the default component is refused when the container is built, like a missing registry seam.

By hand, or with another container

Nothing here is Dishka-specific beyond the provider classes. Any container that can express "one instance per process, closed at shutdown" runs the same two async with blocks; this is what the providers do, spelled out:

from collections.abc import AsyncIterable

from dishka import Provider, Scope, provide

from grpc_client_kit import GrpcClient, GrpcClientFactory
from grpc_client_kit.metrics import get_grpc_client_metrics


class GrpcProvider(Provider):
    @provide(scope=Scope.APP)
    async def get_factory(self, settings: UpstreamSettings) -> AsyncIterable[GrpcClientFactory]:
        async with GrpcClientFactory(settings=settings, metrics=get_grpc_client_metrics()) as factory:
            yield factory

    @provide(scope=Scope.REQUEST)
    def get_user_client(self, factory: GrpcClientFactory) -> GrpcClient[UserStub]:
        return factory.create_client(UserStub, target="user-service:50051")

The async with is what matters: entering it starts and stops health checking, and leaving it closes the pool the factory created — a factory handed a pool= of its own borrows it and closes nothing.