Repository navigation
Description
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
AlexeyShalaev commentedon Sep 15, 2026
Read it against the code and I agree with the shape you landed on.
HeaderProvideris__call__() -> Mapping[str, str]and runtime-checkable;propagation_metadata(keys=None) -> dict[str, str]fits it bare, has been exported from theservicewrightroot with that exact signature since 0.1.0, andimport servicewrightpulls 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.servicewrightbehind a[servicewright]extra, one function,servicewright_headers() -> tuple[HeaderProvider, ...]returning(propagation_metadata,). The module imports servicewright at import time and raises the sameclientwright[servicewright]install hintcontrib.dishkaraises — unlikecontrib.deadlinethere is nothing structural to type here; the whole point is the real function. The extra pinsservicewright>=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.allgrows the same name, andservicewrightgoes into the "Core is stdlib-only" import-linter contract so the direction cannot drift.Intended use:
Rejected: a ready
AdapterDepsbuilder, for the reason you gave — it becomes a second helper the moment one more field is needed, and it competes withcontrib.deadlineinstead of composing. Also rejected: a doc recipe only ("putpropagation_metadatain 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 threadingpropagation_metadata'skeys=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 carriesx-request-id/x-user-idfrom a realbind_contextand nothing outside one; the bare-install probe provesimport clientwright.contrib.servicewrightwithout 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:56question 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.AlexeyShalaev commentedon Sep 15, 2026
Shipped in #42:
clientwright.contrib.servicewrightbehindclientwright[servicewright], one function.servicewright_headers()is(propagation_metadata,)typed astuple[HeaderProvider, ...], so it drops intoheader_providersnext todeadline_source=AmbientDeadlineSource()and next to any provider of your own ((*servicewright_headers(), mine)). The extra pinsservicewright>=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. Yourplatform_rest_depsbecomes that oneAdapterDeps(...)line and the package can go. I ran exactly that againstOriginServerwith a realbind_context(request_id, user_id, tenant_id, trace_id): inside the block the echo shows all fourx-*headers, outside it none.On
emitter.py:56, your reading is right for recording. The emitter substitutesNullMetricswheneverobservability.metricsis 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 thehttp_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.feat: clientwright.contrib.servicewright behind a [servicewright] extra