Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ with use_budget(BudgetContext.create(total_seconds=5.0)):
await client.get("/users") # runs with what is left of those 5 seconds
```

## Request context

With `clientwright[servicewright]`, the ids servicewright bound for the unit of
work being served travel to the upstream as headers:

```python
from clientwright.contrib.servicewright import servicewright_headers

deps = AdapterDeps(header_providers=servicewright_headers()) # x-request-id, x-user-id, ...
```

## From the environment

With `clientwright[settings]`, `ClientConfig` and every sub-config exist as pydantic
Expand Down
47 changes: 47 additions & 0 deletions clientwright/contrib/servicewright.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""servicewright integration (``[servicewright]`` extra).

servicewright binds the ids of the unit of work being served (``request_id``,
``user_id``, ``tenant_id``, ``trace_id``) into its request context, and
``servicewright.propagation_metadata`` reads them back as outbound headers.
That function already satisfies clientwright's ``HeaderProvider`` protocol
bare; this module names the join so no service writes it by hand::

from clientwright.contrib.servicewright import servicewright_headers

deps = AdapterDeps(header_providers=servicewright_headers())
client = build("httpx", config, deps)

await client.get("/users") # carries x-request-id and friends, when bound

Outside a bound context the provider returns nothing and the request goes out
as before. The helper returns the providers tuple rather than a whole
``AdapterDeps`` so it composes with everything else injected there -
``contrib.deadline`` included - instead of competing with it.

Unlike ``contrib.deadline`` there is nothing structural to type here; the real
function is the point, so servicewright is imported at module import time and
a missing extra fails right here with an install hint.
"""

from __future__ import annotations

try:
from servicewright import propagation_metadata
except ImportError as exc: # pragma: no cover - exercised only without the extra
raise ImportError("servicewright integration requires clientwright[servicewright]; install it.") from exc

from ..core.contracts.context import HeaderProvider


def servicewright_headers() -> tuple[HeaderProvider, ...]:
"""The header providers carrying servicewright's request context upstream.

``(propagation_metadata,)`` under servicewright's standard mapping - the
request, user, tenant and trace ids as ``x-request-id``, ``x-user-id``,
``x-tenant-id`` and ``x-trace-id``. For a different mapping build the tuple
yourself: ``(partial(propagation_metadata, keys),)``.
"""
return (propagation_metadata,)


__all__ = ["servicewright_headers"]
8 changes: 5 additions & 3 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
|---|---|
| Package | `clientwright` on PyPI, import root `clientwright` |
| Requires | Python 3.12+. The core has zero dependencies; every adapter needs its own SDK |
| Install | `pip install "clientwright[httpx]"` · extras: `httpx`, `httpx2`, `aiohttp`, `requests`, `urllib3`, `metrics`, `tracing`, `observability`, `deadline`, `dishka`, `settings`, `all` |
| Install | `pip install "clientwright[httpx]"` · extras: `httpx`, `httpx2`, `aiohttp`, `requests`, `urllib3`, `metrics`, `tracing`, `observability`, `deadline`, `dishka`, `settings`, `servicewright`, `all` |
| Async | `build("httpx" | "httpx2" | "aiohttp", config)` — returns the SDK's own async client |
| Sync | `build_sync("httpx" | "httpx2" | "requests" | "urllib3", config)` — returns the SDK's own sync client |
| Source | <https://github.com/bedrock-python/clientwright> |
Expand Down Expand Up @@ -144,7 +144,7 @@ runtime = ClientRuntime.for_config(config) # APP scope: build it ONCE
deps = AdapterDeps(
metrics=PrometheusClientMetrics(), # clientwright[metrics]
tracer=OpenTelemetryTracer(), # clientwright[tracing]
header_providers=(lambda: {"X-Request-ID": "..."},),
header_providers=(lambda: {"X-Request-ID": "..."},), # or servicewright_headers(), clientwright[servicewright]
runtime=runtime,
)
client = build("httpx", config, deps)
Expand Down Expand Up @@ -349,6 +349,7 @@ task-local and thread-local, inherited by tasks started inside it, invisible to
| `clientwright.contrib.dishka` | `ClientwrightProvider(adapter, config, deps=None, *, component=None, client_type=None)` | `Scope.APP` provider giving `ClientRuntime` (circuit-state listener wired), a `ClientHandle` closed in `finally`, and the native client under `client_type`; `component` is one per upstream, resolved with `FromComponent(...)` / `component=` |
| `clientwright.contrib.settings` | `BaseClientSettings` | `ClientConfig` as a pydantic `BaseModel` — nest it in your own `BaseSettings` (`env_nested_delimiter="__"`); `.to_config(service_name)` returns the `ClientConfig` and is the route for these models (`client_config_from_settings` is the flat legacy one). Never a `BaseSettings` itself, so a bare `BASE_URL` cannot reach a section |
| | `BaseTimeoutSettings`, `BasePoolSettings`, `BaseRetrySettings`, `BaseCircuitBreakerSettings`, `BaseTlsSettings`, `BaseProxySettings`, `BaseObservabilitySettings` | the sub-configs, same field names and defaults, each with `.to_config()`. A knob left unset stays `UNSET`; `null` is the explicit "unbounded"; `retry: None = None` in a subclass disables a section; `url_masker` is not a field |
| `clientwright.contrib.servicewright` | `servicewright_headers()` | `tuple[HeaderProvider, ...]` carrying servicewright's request context (`x-request-id`, `x-user-id`, `x-tenant-id`, `x-trace-id`) upstream; goes into `AdapterDeps(header_providers=...)` next to `AmbientDeadlineSource()` |
| `clientwright.core.testing` | `OriginServer()` | in-process fault-injecting origin on an ephemeral localhost port |
| | `RecordingMetrics()` | a `ClientMetricsProtocol` that remembers every record |
| | `ManualClock(start=0.0)` | monotonic clock advanced by hand |
Expand Down Expand Up @@ -660,6 +661,7 @@ Fetch a page when the task is the one named beside it.
| [Capability honesty](guide/capabilities.md) | reading a report, comparing adapters before a migration |
| [Dependency injection](guide/dishka.md) | wiring the runtime and the client lifecycle in a container, one component per upstream |
| [Deadline budgets](guide/deadline-budget.md) | propagating the inbound request's remaining time |
| [Context propagation](guide/context-propagation.md) | carrying servicewright's request ids to the upstream |
| [Testing your service](guide/testing.md) | `OriginServer`, `RecordingMetrics`, what to mock instead |
| [Choosing an adapter](adapters/index.md) | picking one, or planning a swap |
| [httpx](adapters/httpx.md) | the reference adapter's seam and quirks |
Expand All @@ -673,6 +675,6 @@ Fetch a page when the task is the one named beside it.
| [API reference](reference/index.md) | what is covered by semver, and where each surface is documented |
| [Core reference](reference/core.md) | an exact signature or docstring — rendered from source, read it as HTML |
| [Adapters reference](reference/adapters.md) | the per-adapter export table, in full |
| [Contrib reference](reference/contrib.md) | the deadline, dishka and settings surfaces |
| [Contrib reference](reference/contrib.md) | the deadline, dishka, settings and servicewright surfaces |
| [Testing reference](reference/testing.md) | the docstrings of the test instruments |
| [Changelog](changelog.md) | what changed between versions |
67 changes: 67 additions & 0 deletions docs/guide/context-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Context propagation

Your service was handed a request carrying `x-request-id: rid-1`. servicewright's
transports bind that id — and the user, tenant and trace ids next to it — into
its request context for the whole unit of work. Every outgoing call that request
makes should carry the same ids, or the trail stops at your service.

`clientwright[servicewright]` wires that. servicewright's `propagation_metadata`
already satisfies the `HeaderProvider` protocol as it is; the contrib module
names the join so no service writes it by hand.

## Wiring it

```python
from clientwright import AdapterDeps, ClientConfig, build
from clientwright.contrib.servicewright import servicewright_headers

deps = AdapterDeps(header_providers=servicewright_headers())
client = build("httpx", ClientConfig(service_name="orders"), deps)

# inside a unit of work the transport has bound (or your own bind_context block):
await client.get("/inventory") # x-request-id, x-user-id, x-tenant-id, x-trace-id
```

Only the ids actually bound travel: outside any bound context the provider
returns nothing and the request goes out unchanged. A header the caller set
explicitly is never overwritten — providers fill in what is missing.

## Composing

`servicewright_headers()` returns the providers tuple rather than a whole
`AdapterDeps`, so it sits next to everything else you inject:

```python
from clientwright.adapters.observability import PrometheusClientMetrics
from clientwright.contrib.deadline import AmbientDeadlineSource
from clientwright.contrib.servicewright import servicewright_headers

deps = AdapterDeps(
metrics=PrometheusClientMetrics(),
header_providers=servicewright_headers(),
deadline_source=AmbientDeadlineSource(),
)
```

Your own providers go in the same tuple: `(*servicewright_headers(), my_provider)`.

## A different header mapping

`propagation_metadata` takes a `{context_key: header_name}` mapping; the helper
uses servicewright's standard one. For another, build the tuple yourself:

```python
from functools import partial

from servicewright import propagation_metadata

deps = AdapterDeps(header_providers=(partial(propagation_metadata, {"request_id": "X-Correlation-ID"}),))
```

## Why an extra

Unlike `contrib.deadline`, this module imports servicewright at import time —
the real function is the point, there is nothing structural to type — so a
missing extra fails at `import clientwright.contrib.servicewright` with an
install hint. The extra pins `servicewright>=0.1,<1`; the export has not changed
since 0.1.0.
3 changes: 3 additions & 0 deletions docs/guide/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ deps = AdapterDeps(
client = build("httpx", ClientConfig(service_name="orders"), deps)
```

In a servicewright service the lambda is `servicewright_headers()` from
`clientwright[servicewright]` — see [Context propagation](context-propagation.md).

Both backends are optional protocols — `ClientMetricsProtocol` and
`TracerProtocol` are structural, so plugging in a custom sink means implementing
methods, not inheriting classes. The test double `RecordingMetrics` (see
Expand Down
1 change: 1 addition & 0 deletions docs/learn/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pip install clientwright[httpx]
| `deadline` | `deadline-budget` | ambient request-budget propagation |
| `dishka` | `dishka>=1.4` | the DI provider with a leak-free lifecycle |
| `settings` | `pydantic>=2,<3`, `pydantic-settings>=2.3,<3` | `ClientConfig` from the environment, one pydantic model per sub-config |
| `servicewright` | `servicewright>=0.1,<1` | servicewright's request context as outbound headers |
| `all` | everything above | kitchen sink for experiments |

Extras combine the way you expect:
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/contrib.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ copy_page: false

::: clientwright.contrib.settings

## servicewright

::: clientwright.contrib.servicewright

## Dishka

`clientwright.contrib.dishka` imports `dishka` at module import time (by design —
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Auto-generated from source docstrings. The prose lives in the
|---|---|
| [Core](core.md) | `build` / `build_sync` / `inspect`, `ClientConfig` and all sub-configs, the data model, errors, capabilities, plans and runtime |
| [Adapters](adapters.md) | per-adapter public exports: per-call channels and dual-family errors |
| [Contrib](contrib.md) | deadline-budget, dishka and pydantic settings integrations |
| [Contrib](contrib.md) | deadline-budget, dishka, pydantic settings and servicewright integrations |
| [Testing](testing.md) | `OriginServer`, `RecordingMetrics`, `ManualClock` |

## What is stable
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ observability = ["clientwright[metrics,tracing]"]
deadline = ["deadline-budget>=0.1,<1"]
dishka = ["dishka>=1.4"]
settings = ["pydantic>=2,<3", "pydantic-settings>=2.3,<3"]
all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka,settings]"]
servicewright = ["servicewright>=0.1,<1"]
all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka,settings,servicewright]"]

[dependency-groups]
test = [
Expand Down Expand Up @@ -180,7 +181,7 @@ source_modules = ["clientwright.core"]
forbidden_modules = [
"httpx", "httpx2", "aiohttp", "requests", "urllib3", "niquests",
"tornado", "aiosonic", "pydantic", "dishka", "prometheus_client",
"opentelemetry", "structlog", "sentry_sdk",
"opentelemetry", "structlog", "sentry_sdk", "servicewright",
]

# _httpx_shared is deliberately OUTSIDE this list: it is the one sanctioned
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/contrib/test_servicewright.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""servicewright's request context flowing through the engine against a real origin."""

from __future__ import annotations

import pytest

httpx = pytest.importorskip("httpx", reason="requires the [httpx] extra")
servicewright = pytest.importorskip("servicewright", reason="requires the [servicewright] extra")

import clientwright # noqa: E402
from clientwright import AdapterDeps # noqa: E402
from clientwright.contrib.servicewright import servicewright_headers # noqa: E402
from clientwright.core.testing import OriginServer # noqa: E402

from ..conftest import base_config # noqa: E402


async def test__bound_context__travels_as_headers_and_only_while_bound(origin: OriginServer) -> None:
wired = AdapterDeps(header_providers=servicewright_headers())
client = clientwright.build("httpx", base_config(origin), wired)
assert isinstance(client, httpx.AsyncClient)
with servicewright.bind_context(request_id="rid-1", tenant_id="acme"):
bound = (await client.get("/echo")).json()["headers"]
outside = (await client.get("/echo")).json()["headers"]
await client.aclose()
assert bound["x-request-id"] == "rid-1"
assert bound["x-tenant-id"] == "acme"
assert "x-request-id" not in outside
assert "x-tenant-id" not in outside
15 changes: 15 additions & 0 deletions tests/unit/adapters/test_bare_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"deadline_budget",
"pydantic",
"pydantic_settings",
"servicewright",
)

BLOCKER = """
Expand Down Expand Up @@ -163,3 +164,17 @@ def test__settings_models_without_the_extra__fail_with_the_install_hint() -> Non
"""
)
assert "clientwright[settings]" in output


def test__contrib_servicewright_without_the_extra__fails_with_the_install_hint() -> None:
output = run_probe(
"""
try:
import clientwright.contrib.servicewright
except ImportError as error:
print(error)
else:
print("NO ERROR")
"""
)
assert "clientwright[servicewright]" in output
23 changes: 23 additions & 0 deletions tests/unit/contrib/test_servicewright.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""contrib units: the servicewright header providers."""

from __future__ import annotations

import pytest

servicewright = pytest.importorskip("servicewright", reason="requires the [servicewright] extra")

from clientwright import HeaderProvider # noqa: E402
from clientwright.contrib.servicewright import servicewright_headers # noqa: E402


def test__providers__are_the_bare_function_and_satisfy_the_protocol() -> None:
providers = servicewright_headers()
assert providers == (servicewright.propagation_metadata,)
assert all(isinstance(provider, HeaderProvider) for provider in providers)


def test__provider__reads_the_bound_context_and_nothing_outside_it() -> None:
(provider,) = servicewright_headers()
with servicewright.bind_context(request_id="rid-1", user_id="u-9"):
assert provider() == {"x-request-id": "rid-1", "x-user-id": "u-9"}
assert provider() == {}
1 change: 1 addition & 0 deletions tests/unit/test_sdk_imports_are_guarded.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"deadline_budget",
"pydantic",
"pydantic_settings",
"servicewright",
}
)

Expand Down
17 changes: 16 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions zensical.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ nav = [
{ "Capability honesty" = "guide/capabilities.md" },
{ "Dependency injection" = "guide/dishka.md" },
{ "Deadline budgets" = "guide/deadline-budget.md" },
{ "Context propagation" = "guide/context-propagation.md" },
{ "Testing your service" = "guide/testing.md" },
] },
{ "Adapters" = [
Expand Down