feat: component= and client_type= on ClientwrightProvider, and the circuit-state gauge on its runtime - #36
Merged
Conversation
…rcuit-state gauge on its runtime Both provides keyed on ClientRuntime and ClientHandle[Any] in the default component, so a second upstream in one container was a collision by construction. component= goes to Provider.__init__ like every provider in grpc_client_kit.dishka; dishka resolves the handle's runtime inside the provider's own component, so each upstream is its own runtime, client and close. client_type= also provides the native client under that type, from the same handle, in the same component. The runtime provide now builds a ClientTelemetry the way an adapter does and wires it as the circuit listener, so http_client_circuit_state moves on a provider-built runtime; a runtime passed in through deps.runtime is handed back as it came. Closes #35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #35.
Problem
ClientwrightProviderhad nocomponent=, so its two provides —ClientRuntimeandClientHandle[Any]— always landed in dishka's default component, and a second upstream in the same container was a collision by construction. The docstring's answer was "separate containers, or subclass", which is not available to a service whose upstreams are dependencies of the same objects (the reporter has eight clients across three services, two of them handed to one constructor). Two smaller things sat in the same decision: injection sites wanthttpx.AsyncClient, notClientHandle[Any]; andhttp_client_circuit_statenever moved on a provider-built runtime, because every adapter attaches the listener only on theClientRuntime.for_config(...)branch it takes whendeps.runtimeisNone— and the provider always hands a runtime in.Design
ClientwrightProvider(adapter, config, deps=None, *, component=None, client_type=None).component=goes straight toProvider.__init__, as every provider ingrpc_client_kit.dishkadoes. Dishka resolves the handle'sClientRuntimedependency inside the provider's own component, so one provider per upstream is one runtime, one client and one close per upstream, nothing shared. The default component is untouched.client_type=registers one more provide on the instance (self.provide(self._native_client, provides=client_type)): the native client under that type, out of the same handle, in the same component, APP-scoped and cached.Annotated[httpx.AsyncClient, FromComponent("github-api")]at the injection site; the two-client constructor from the issue works as written.ClientTelemetrythe way an adapter does (adapter.name,capabilities.seam,config.observability,deps.metrics) and passescircuit_listener=telemetry.circuit_state_changed. No sink or metrics off →NullMetrics, the adapters' own gating. A runtime passed throughdeps.runtimeis handed back as it came — same asbuild(); rule 2 on the agents page now says so.@provide; the body ofclient_handleis unchanged.No bundle helper:
grpc_client_providersexists because that kit has three providers per upstream. Here the provider is the bundle.Rejected
httpx.AsyncClientprovide — the module serves five adapters and cannot name an SDK type, and the[dishka]extra does not pull httpx.CircuitRegistrytakes its listener in the constructor, and a runtime shared by several clients has no single owner for it.ClientwrightProvider[httpx.AsyncClient]— no runtime type for dishka's key at construction time.Tests
test__two_components__serve_two_upstreams_from_one_container— distinct handles, distinct runtimes, each runtime the one its component hands out, both clients closed oncontainer.close().test__client_type__provides_the_native_client_the_handle_holdsandtest__client_type_per_component__injects_each_upstream_by_name— the second is the reporter'sGitHubOAuthProvider(login_client, api_client)shape viaFromComponent.test__request_scope__leaves_the_client_open_until_the_container_closes— the property the issue cares about: a request scope coming and going does not touch the client; the container's close does.test__runtime_built_by_provider__moves_the_circuit_state_gauge— a trip on a provider-built runtime reads2.0fromhttp_client_circuit_stateon a realCollectorRegistry./status/500and rejects locally, the other still serves, both transitions reachRecordingMetrics, both clients closed.With
origin/master's module swapped in (git show origin/master:clientwright/contrib/dishka.py > clientwright/contrib/dishka.py), the four new tests fail —unexpected keyword argument 'component'/'client_type', and the gauge sample isNone— and the two behaviour-preserving ones pass:4 failed, 2 passed.Docs
guide/dishka.md(typed injection, several upstreams as components, the gauge section),reference/contrib.md,agents.md(provider row, telemetry table row, rule 2, page map), the README's DI paragraph, the module docstring.uv run --isolated --no-dev --group docs zensical build --clean→No issues found.Verification
Baseline on
origin/master:make check→Contracts: 3 kept, 0 broken;make test→854 passed, 42 skipped, coverage 99.61%.After:
The two uncovered lines in
dishka.pyare the pre-existing sync-close fallback (elif handle.close is not None: handle.close()), not touched here.uv.lockuntouched.