Skip to content

ClientwrightProvider takes no component=, so several upstreams in one container are unreachable #35

Description

@AlexeyShalaev

We went to adopt clientwright.contrib.dishka and could not register it, for the reason its
own docstring names:

For several upstreams, instantiate one provider per upstream in separate containers, or
subclass and add typed aliases.

Separate containers is not available to us — the upstreams are dependencies of the same
use cases — and ClientwrightProvider takes no component=, so two of them in one container
both provide ClientRuntime and ClientHandle[Any] on the same keys.

What that looks like here

social-idp-bridge builds five clients from one container — Google OAuth, GitHub login,
GitHub API, LinkedIn login, LinkedIn API — each with its own ClientConfig, two of them
handed to the same object:

@provide
async def get_github_oauth_provider(self, settings: Settings, ...) -> AsyncIterator[GitHubOAuthProvider]:
    async with (
        self.rest_client(settings.oauth.github.client,     "github-oauth") as login_client,
        self.rest_client(settings.oauth.github.api_client, "github-api")   as api_client,
    ):
        yield GitHubOAuthProvider(login_client=login_client, api_client=api_client, ...)

auth-orchestrator has two (credential-service, social-idp-bridge), session-token-service
one. Eight clients across three services, none of which can be one-provider-per-container.

The asymmetry worth noting

grpc_client_kit.dishka — the sibling module in the same family — solved exactly this:

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")

Every provider there takes component=, and the docs explain the trade-off. The same
component= on ClientwrightProvider would make one-container-many-upstreams a supported
shape rather than a documented limitation, with no change to the single-upstream case.

Two smaller things in the way

Typed injection. The provider hands out ClientHandle[Any]; consumers want the native
client. The docstring suggests subclassing to add a typed alias, which is fine once
component= exists — but a shipped httpx.AsyncClient alias (or a documented two-line
recipe) would save every consumer writing the same subclass.

The circuit-state gauge. The module already notes it:

the http_client_circuit_state gauge is wired when the ADAPTER builds the runtime; a
runtime built here (to be shared) has no telemetry listener, so state-change events are not
exported as a gauge.

That is a metric we currently export, so adopting the provider as it stands would be a
regression on our dashboards. Not a blocker on its own, but it lands in the same decision.

Where we are

We keep a 73-line RestClientProvider whose whole job is "several clientwright clients from
one provider, each closed when its scope ends". It is a base class services subclass with one
generator @provide per upstream. We would rather delete it.

Worth saying that writing it the naive way is a trap you already flagged: ours parked
aclose on an AsyncExitStack nobody closed and leaked a client per request, at
Scope.REQUEST — the legacy-kit bug your docstring calls out, arrived at independently. We
have just fixed it the way you prescribe, close travelling with the provide. Whatever shape
the multi-upstream answer takes, that property is the one we care about keeping.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions