Skip to content

feat: component= and client_type= on ClientwrightProvider, and the circuit-state gauge on its runtime - #36

Merged
AlexeyShalaev merged 1 commit into
masterfrom
feat/dishka-components
Sep 14, 2026
Merged

AlexeyShalaev merged 1 commit into
masterfrom
feat/dishka-components

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

Closes #35.

Problem

ClientwrightProvider had no component=, so its two provides — ClientRuntime and ClientHandle[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 want httpx.AsyncClient, not ClientHandle[Any]; and http_client_circuit_state never moved on a provider-built runtime, because every adapter attaches the listener only on the ClientRuntime.for_config(...) branch it takes when deps.runtime is None — and the provider always hands a runtime in.

Design

ClientwrightProvider(adapter, config, deps=None, *, component=None, client_type=None).

  • component= goes straight to Provider.__init__, as every provider in grpc_client_kit.dishka does. Dishka resolves the handle's ClientRuntime dependency 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.
  • The runtime provide builds a ClientTelemetry the way an adapter does (adapter.name, capabilities.seam, config.observability, deps.metrics) and passes circuit_listener=telemetry.circuit_state_changed. No sink or metrics off → NullMetrics, the adapters' own gating. A runtime passed through deps.runtime is handed back as it came — same as build(); rule 2 on the agents page now says so.
  • Close still travels with the generator @provide; the body of client_handle is unchanged.

No bundle helper: grpc_client_providers exists because that kit has three providers per upstream. Here the provider is the bundle.

Rejected

  • A built-in httpx.AsyncClient provide — the module serves five adapters and cannot name an SDK type, and the [dishka] extra does not pull httpx.
  • Keeping the subclass recipe as the answer to typed injection — it leaves every service writing the class the reporter wants to delete.
  • Attaching the listener from the adapter to a runtime it did not build — CircuitRegistry takes its listener in the constructor, and a runtime shared by several clients has no single owner for it.
  • A generic 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 on container.close().
  • test__client_type__provides_the_native_client_the_handle_holds and test__client_type_per_component__injects_each_upstream_by_name — the second is the reporter's GitHubOAuthProvider(login_client, api_client) shape via FromComponent.
  • 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 reads 2.0 from http_client_circuit_state on a real CollectorRegistry.
  • Integration: two components against the origin, one breaker trips through /status/500 and rejects locally, the other still serves, both transitions reach RecordingMetrics, both clients closed.
  • The pre-existing default-component test is unchanged and passes.

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 is None — 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 --cleanNo issues found.

Verification

Baseline on origin/master: make checkContracts: 3 kept, 0 broken; make test854 passed, 42 skipped, coverage 99.61%.

After:

$ make check
All checks passed!
235 files already formatted
Success: no issues found in 98 source files
Contracts: 3 kept, 0 broken.

$ make test
clientwright/contrib/dishka.py    40    2   95%
Required test coverage of 97% reached. Total coverage: 99.61%
861 passed, 42 skipped in 115.79s

The two uncovered lines in dishka.py are the pre-existing sync-close fallback (elif handle.close is not None: handle.close()), not touched here. uv.lock untouched.

…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
@AlexeyShalaev
AlexeyShalaev merged commit 0ac6cdf into master Sep 14, 2026
8 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the feat/dishka-components branch September 14, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant