Skip to content

Every consumer of clientwright + servicewright writes the same AdapterDeps join by hand #41

Closed

Description

@AlexeyShalaev

We are down to one function in the package that used to hold our DI wiring, and looking at
what is actually in it: nothing of ours.

def platform_rest_deps(*, metrics_enabled: bool) -> AdapterDeps:
    return AdapterDeps(
        metrics=PrometheusClientMetrics() if metrics_enabled else None,
        header_providers=(propagation_metadata,),
    )

PrometheusClientMetrics is yours. propagation_metadata is servicewright's, and it
already satisfies your HeaderProvider protocol as it is — no adapter, no wrapper, the bare
function goes in the tuple. The only thing this function contributes is that these two go
together
, which is true for every consumer running both kits.

The seam this fills is one you already fill elsewhere

clientwright.contrib.deadline exists for exactly this shape: the core stays
zero-dependency, the structural protocol (DeadlineSource) is the seam, and the contrib
module supplies a source backed by another kit's object, behind the [deadline] extra:

deps = AdapterDeps(deadline_source=AmbientDeadlineSource())

The propagation case is the same sentence with a different noun. HeaderProvider is the
seam, servicewright's ambient context store is the other kit's object, and every consumer of
both writes the join by hand.

Neither package depends on the other today, so a [servicewright] extra carries no cycle —
the same position [deadline] is in.

What we would use

clientwright.contrib.servicewright with something like

from clientwright.contrib.servicewright import propagation_deps

deps = propagation_deps(metrics=PrometheusClientMetrics())
# or simply
deps = AdapterDeps(header_providers=servicewright_headers())

Shape is yours to pick — a ready AdapterDeps, or just the tuple to compose. The second is
probably better: AdapterDeps has six fields and a consumer usually sets two or three, so a
helper that returns the whole object invites a second helper the moment someone needs
deadline_source as well. Something that returns the header providers composes with
contrib.deadline instead of competing with it.

One thing we got wrong, worth knowing

Our metrics_enabled is a service-wide gate on top of yours, and reading
core/telemetry/emitter.py:56:

self._metrics = metrics if (metrics and config.metrics) else NullMetrics()

config.observability.metrics already decides per client, so our extra gate mostly does not
do anything — handing the registry in unconditionally would behave the same for a service
with metrics off, since every client's own flag is off too. We will drop it. Mentioning it
because it is the reason the function looked like it had platform content when it does not:
the gate was ours, and it was redundant.

Context: ai-ops, 3 services on clientwright + servicewright, 8 REST upstreams between them.
This function is the last thing in a package we would otherwise delete — the client
providers went to contrib.dishka in 0.4.0, the metrics providers went to the kits that own
the collectors, and the settings went to servicewright 0.13.0. Not urgent: it is one small
function, and we can carry it. But it is upstream-shaped, and the next consumer of both kits
will write it again.

Activity

  1. AlexeyShalaev commented on Sep 15, 2026

    @AlexeyShalaev
    MemberAuthor

    Read it against the code and I agree with the shape you landed on.

    HeaderProvider is __call__() -> Mapping[str, str] and runtime-checkable; propagation_metadata(keys=None) -> dict[str, str] fits it bare, has been exported from the servicewright root with that exact signature since 0.1.0, and import servicewright pulls in nothing third-party. servicewright has no dependency on clientwright, so the extra carries no cycle — same position [deadline] is in.

    What I'm going to do: clientwright.contrib.servicewright behind a [servicewright] extra, one function, servicewright_headers() -> tuple[HeaderProvider, ...] returning (propagation_metadata,). The module imports servicewright at import time and raises the same clientwright[servicewright] install hint contrib.dishka raises — unlike contrib.deadline there is nothing structural to type here; the whole point is the real function. The extra pins servicewright>=0.1,<1, the way [deadline] pins deadline-budget. The floor is honest because the export has not moved since 0.1.0, and I am not putting a single-minor cap on a library that moves a minor a week. all grows the same name, and servicewright goes into the "Core is stdlib-only" import-linter contract so the direction cannot drift.

    Intended use:

    deps = AdapterDeps(header_providers=servicewright_headers(), deadline_source=AmbientDeadlineSource())

    Rejected: a ready AdapterDeps builder, for the reason you gave — it becomes a second helper the moment one more field is needed, and it competes with contrib.deadline instead of composing. Also rejected: a doc recipe only ("put propagation_metadata in the tuple"). That leaves no extra to pin the version and nothing to import, which is exactly the state every consumer is re-deriving today. I'm not threading propagation_metadata's keys= through the helper: nobody has asked, and (partial(propagation_metadata, keys),) is the one-liner if someone does.

    Tests: the tuple passes isinstance(..., HeaderProvider) and the module type-checks against the protocol under mypy; an httpx request through the adapter carries x-request-id/x-user-id from a real bind_context and nothing outside one; the bare-install probe proves import clientwright.contrib.servicewright without servicewright raises the install hint; the guard-scan and blocker lists learn the new SDK root. Docs: a guide page next to Deadline budgets, the contrib reference section, the install matrix, README, agents.md.

    The emitter.py:56 question I'll answer in the reply — the short version is you are right about recording, and there is one small thing that is not identical.

  2. AlexeyShalaev commented on Sep 15, 2026

    @AlexeyShalaev
    MemberAuthor

    Shipped in #42: clientwright.contrib.servicewright behind clientwright[servicewright], one function.

    from clientwright.contrib.servicewright import servicewright_headers
    
    deps = AdapterDeps(metrics=PrometheusClientMetrics(), header_providers=servicewright_headers())

    servicewright_headers() is (propagation_metadata,) typed as tuple[HeaderProvider, ...], so it drops into header_providers next to deadline_source=AmbientDeadlineSource() and next to any provider of your own ((*servicewright_headers(), mine)). The extra pins servicewright>=0.1,<1: the export has not moved since 0.1.0, and I did not want a per-minor cap on a library that bumps a minor a week. Your platform_rest_deps becomes that one AdapterDeps(...) line and the package can go. I ran exactly that against OriginServer with a real bind_context(request_id, user_id, tenant_id, trace_id): inside the block the echo shows all four x-* headers, outside it none.

    On emitter.py:56, your reading is right for recording. The emitter substitutes NullMetrics whenever observability.metrics is off on the client, so with every client's flag off, handing the backend in unconditionally records nothing. One thing is not identical: PrometheusClientMetrics() registers the http_client_* families in the default registry at construction (it is a per-registry singleton), so a service with metrics "off" still exposes those families, empty, on /metrics. If that bothers you, keep the gate only at construction (metrics=PrometheusClientMetrics() if enabled else None) and let the per-client flag do the rest; if it does not, drop it as you planned.

    It is a feat:, so it goes out with the next minor once merged.

  3. added a commit that references this issue on Sep 15, 2026
    777c5f3
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