From c9f3d09bf2a6b4428dd53f2c4d80d02cdb8a3d48 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:21:05 +0300 Subject: [PATCH 1/5] fix(telemetry): surface the connection timings adapters already collect ``conn_metrics`` sits on both normalizer protocols and every adapter implements it - aiohttp does real work for it through ``TraceConfig`` - but neither engine ever called it. ``Attempt.conn`` was therefore always ``None`` and aiohttp's ``conn_metrics: native`` declaration bought a caller nothing. Both engines now fill ``Attempt.conn`` from the normalizer, and the emitter hangs what the adapter saw on the call span as ``http.connection.*`` plus ``network.protocol.version``. A phase the adapter cannot observe stays off the span instead of being reported as a zero. The metric families are a frozen contract and are untouched; ``ClientTelemetry.attempt_end`` takes the observation first, like ``call_end``. --- clientwright/core/engine/aio.py | 3 +- clientwright/core/engine/sync.py | 3 +- clientwright/core/telemetry/emitter.py | 23 ++++++++++-- docs/adapters/aiohttp.md | 4 +++ docs/agents.md | 10 +++++- docs/guide/observability.md | 9 +++++ tests/helpers/engine.py | 18 ++++++---- tests/helpers/telemetry.py | 41 +++++++++++++++++++++ tests/integration/adapters/test_aiohttp.py | 14 ++++++++ tests/unit/core/engine/test_aio.py | 28 ++++++++++++++- tests/unit/core/engine/test_sync.py | 13 +++++++ tests/unit/core/telemetry/test_emitter.py | 42 ++++++++++++++++++++-- 12 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 tests/helpers/telemetry.py diff --git a/clientwright/core/engine/aio.py b/clientwright/core/engine/aio.py index 957552b..e0b5104 100644 --- a/clientwright/core/engine/aio.py +++ b/clientwright/core/engine/aio.py @@ -214,10 +214,11 @@ async def _attempts( duration=runtime.clock() - attempt_started, outcome=outcome, hop=observation.hops, + conn=self._norm.conn_metrics(response) if response is not None else None, ) history.append(attempt) if plan.emit_attempt_metrics: - self._telemetry.attempt_end(info, attempt) + self._telemetry.attempt_end(observation, info, attempt) if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired: raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception if plan.retry_policy is None: diff --git a/clientwright/core/engine/sync.py b/clientwright/core/engine/sync.py index f42fc99..fa7a6d6 100644 --- a/clientwright/core/engine/sync.py +++ b/clientwright/core/engine/sync.py @@ -209,10 +209,11 @@ def _attempts( duration=runtime.clock() - attempt_started, outcome=outcome, hop=observation.hops, + conn=self._norm.conn_metrics(response) if response is not None else None, ) history.append(attempt) if plan.emit_attempt_metrics: - self._telemetry.attempt_end(info, attempt) + self._telemetry.attempt_end(observation, info, attempt) if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired: raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception if plan.retry_policy is None: diff --git a/clientwright/core/telemetry/emitter.py b/clientwright/core/telemetry/emitter.py index 357feb5..7b0769d 100644 --- a/clientwright/core/telemetry/emitter.py +++ b/clientwright/core/telemetry/emitter.py @@ -12,7 +12,7 @@ from ..config import ObservabilityConfig from ..contracts.observability import ClientMetricsProtocol, SpanProtocol, TracerProtocol -from ..model import Attempt, Outcome, RequestInfo +from ..model import Attempt, ConnMetrics, Outcome, RequestInfo from .names import OUTCOME_SUCCESS, ROUTE_UNKNOWN, STATUS_NONE from .null import NullMetrics, NullTracer from .redaction import REDACTED, redact_url @@ -97,7 +97,7 @@ def call_start(self, info: RequestInfo, started: float) -> CallObservation: ) return CallObservation(span=span, started=started) - def attempt_end(self, info: RequestInfo, attempt: Attempt) -> None: + def attempt_end(self, observation: CallObservation, info: RequestInfo, attempt: Attempt) -> None: self._metrics.record_attempt( service=self._service, adapter=self._adapter, @@ -107,6 +107,25 @@ def attempt_end(self, info: RequestInfo, attempt: Attempt) -> None: outcome=outcome_label(attempt.outcome), duration=attempt.duration, ) + if attempt.conn is not None: + self._record_conn(observation.span, attempt.conn) + + def _record_conn(self, span: SpanProtocol, conn: ConnMetrics) -> None: + """Connection timings onto the call span; the last attempt that saw them wins. + + The metric families are a frozen contract with no room for them, so the + span is where an adapter that can observe them surfaces them. + """ + for key, value in ( + ("http.connection.dns_duration", conn.dns), + ("http.connection.connect_duration", conn.connect), + ("http.connection.tls_duration", conn.tls), + ("http.connection.pool_wait_duration", conn.pool_wait), + ("http.connection.reused", conn.reused), + ("network.protocol.version", conn.http_version), + ): + if value is not None: + span.set_attribute(key, value) def redirect_hop(self, observation: CallObservation) -> None: observation.hops += 1 diff --git a/docs/adapters/aiohttp.md b/docs/adapters/aiohttp.md index 6dd296e..e96720f 100644 --- a/docs/adapters/aiohttp.md +++ b/docs/adapters/aiohttp.md @@ -77,5 +77,9 @@ not as chores: httpx). - **Pool wait**: folded by aiohttp into the connect phase — `pool_timeout` is declared collapsed into `connect_timeout`. +- **Connection timings**: the only adapter with `conn_metrics: native`. The + `TraceConfig` times DNS, connect and pool wait and records whether the + connection was reused; the engine hangs them on the call span as + `http.connection.*` — see [Observability](../guide/observability.md#traces). - **Errors**: dual-family as everywhere — `AiohttpCircuitOpenError` is both a `CircuitOpenError` and an `aiohttp.ClientError`. diff --git a/docs/agents.md b/docs/agents.md index 9bf841a..5bb30a2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -374,7 +374,15 @@ Metric names and label sets are a frozen wire contract in | `http_client_uninstrumented_calls_total` | counter | aiohttp only: a request that bypassed the middleware | `outcome` is `success` or a `FailureKind` value; `status` is the numeric status or the -string `none`; `route` is `unknown` until a call site sets it. Backends: +string `none`; `route` is `unknown` until a call site sets it. + +One `CLIENT` span per logical call, with `http.request.method`, `server.origin`, a redacted +`url.full` and `http.response.status_code`. An adapter whose `conn_metrics` capability is +`native` (aiohttp only) also annotates it with the connection timings of the last attempt +that could see them: `http.connection.dns_duration`, `http.connection.connect_duration`, +`http.connection.tls_duration`, `http.connection.pool_wait_duration`, +`http.connection.reused`, `network.protocol.version`. A phase the adapter cannot observe is +absent from the span, never zero. Backends: `clientwright.adapters.observability.PrometheusClientMetrics(prefix=None, registry=REGISTRY, buckets=...)` (cached per registry and prefix) and `OpenTelemetryTracer(tracer_provider=None)`. diff --git a/docs/guide/observability.md b/docs/guide/observability.md index 203b589..1e28f35 100644 --- a/docs/guide/observability.md +++ b/docs/guide/observability.md @@ -68,6 +68,15 @@ the upstream's server span becomes its child, not its sibling. Attempts and redirect hops stay inside the one span: your trace waterfall shows the call as your caller experienced it. +An adapter that can observe connection timings (`conn_metrics` in the +[capability matrix](capabilities.md) — aiohttp today) adds them to the span: +`http.connection.dns_duration`, `http.connection.connect_duration`, +`http.connection.tls_duration`, `http.connection.pool_wait_duration`, +`http.connection.reused` and `network.protocol.version`. A phase the adapter did +not see is left off the span rather than reported as a zero, and on a retried +call the last attempt that saw them wins — the reused-connection attempt shows +no connect duration, which is the point. + ## Logs The built-in logging channel writes structured records to the standard `logging` diff --git a/tests/helpers/engine.py b/tests/helpers/engine.py index 4cc97d6..7c3fcac 100644 --- a/tests/helpers/engine.py +++ b/tests/helpers/engine.py @@ -22,10 +22,11 @@ ) from clientwright.core.config import ClientConfig, RetryConfig from clientwright.core.contracts.adapter import AdapterDeps +from clientwright.core.contracts.observability import TracerProtocol from clientwright.core.engine.aio import AsyncAttemptEngine from clientwright.core.engine.base import default_response_outcome from clientwright.core.engine.sync import SyncAttemptEngine -from clientwright.core.model import FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of +from clientwright.core.model import ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of from clientwright.core.plan import ClientRuntime, compile_plan from clientwright.core.telemetry.emitter import ClientTelemetry from clientwright.core.testing import RecordingMetrics @@ -182,12 +183,13 @@ async def send(request: EngineRequest) -> Any: class FakeNormalizer: """Both normalizer flavors in one object; the request/response ARE the views.""" - def __init__(self, *, freeze_ok: bool = True) -> None: + def __init__(self, *, freeze_ok: bool = True, conn: ConnMetrics | None = None) -> None: self.freeze_ok = freeze_ok self.freezes = 0 self.rewinds = 0 self.discards = 0 self.wrapped_streams = 0 + self.conn = conn def wrap_request(self, native: Any) -> EngineRequest: assert isinstance(native, EngineRequest) @@ -212,8 +214,8 @@ def classify_response(self, response: FakeResponse) -> Outcome: def wrap_stream(self, response: FakeResponse, on_done: Callable[[Outcome, float], None]) -> None: self.wrapped_streams += 1 - def conn_metrics(self, response: FakeResponse) -> None: - return None + def conn_metrics(self, response: FakeResponse) -> ConnMetrics | None: + return self.conn # -- sync flavor ------------------------------------------------------- @@ -261,6 +263,8 @@ def __init__( freeze_ok: bool = True, sync: bool = False, clock: Callable[[], float] | None = None, + tracer: TracerProtocol | None = None, + conn: ConnMetrics | None = None, ) -> None: self.config = config self.metrics = RecordingMetrics() @@ -273,11 +277,11 @@ def __init__( seam="test", config=config.observability, metrics=self.metrics, - tracer=None, + tracer=tracer, ) self.normalizer: FakeSyncNormalizer | FakeAsyncNormalizer if sync: - self.normalizer = FakeSyncNormalizer(freeze_ok=freeze_ok) + self.normalizer = FakeSyncNormalizer(freeze_ok=freeze_ok, conn=conn) self.engine: Any = SyncAttemptEngine( plan=self.plan, runtime=self.runtime, @@ -287,7 +291,7 @@ def __init__( translate=lambda error: error, ) else: - self.normalizer = FakeAsyncNormalizer(freeze_ok=freeze_ok) + self.normalizer = FakeAsyncNormalizer(freeze_ok=freeze_ok, conn=conn) self.engine = AsyncAttemptEngine( plan=self.plan, runtime=self.runtime, diff --git a/tests/helpers/telemetry.py b/tests/helpers/telemetry.py new file mode 100644 index 0000000..f265814 --- /dev/null +++ b/tests/helpers/telemetry.py @@ -0,0 +1,41 @@ +"""Telemetry doubles: a tracer that keeps every span it opened.""" + +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class RecordingSpan: + """SpanProtocol double keeping every attribute the emitter sets.""" + + attributes: dict[str, object] = field(default_factory=dict) + ended: bool = False + + def set_attribute(self, key: str, value: str | int | float | bool) -> None: + self.attributes[key] = value + + def record_failure(self, description: str) -> None: + self.attributes["failure"] = description + + def end(self) -> None: + self.ended = True + + +@dataclass(slots=True) +class RecordingTracer: + """TracerProtocol double; one RecordingSpan per logical call.""" + + spans: list[RecordingSpan] = field(default_factory=list) + + def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> RecordingSpan: + span = RecordingSpan(attributes=dict(attributes)) + self.spans.append(span) + return span + + def inject_context(self, headers: MutableMapping[str, str]) -> None: + return + + +__all__ = ["RecordingSpan", "RecordingTracer"] diff --git a/tests/integration/adapters/test_aiohttp.py b/tests/integration/adapters/test_aiohttp.py index 4b679fe..22d9b48 100644 --- a/tests/integration/adapters/test_aiohttp.py +++ b/tests/integration/adapters/test_aiohttp.py @@ -21,6 +21,7 @@ ) from clientwright.core.config import CircuitBreakerConfig # noqa: E402 from clientwright.core.testing import OriginServer, RecordingMetrics # noqa: E402 +from tests.helpers.telemetry import RecordingTracer # noqa: E402 from ..conftest import base_config # noqa: E402 @@ -239,3 +240,16 @@ async def test__failure_paths__never_leak_inflight( statuses = [record["status"] for record in metrics.calls] assert statuses.count("none") == 2 # both failures observed with status=none await client.close() + + +async def test__conn_metrics__annotate_the_call_span(origin: OriginServer) -> None: + tracer = RecordingTracer() + client = await build(base_config(origin), AdapterDeps(tracer=tracer)) + await client.get("/echo") + await client.get("/echo") + await client.close() + fresh, pooled = (span.attributes for span in tracer.spans) + assert fresh["http.connection.connect_duration"] >= 0.0 # TraceConfig timed the handshake + assert fresh["http.connection.reused"] is False + assert pooled["http.connection.reused"] is True # second call took the pooled connection + assert "http.connection.connect_duration" not in pooled diff --git a/tests/unit/core/engine/test_aio.py b/tests/unit/core/engine/test_aio.py index 66f04c0..6552096 100644 --- a/tests/unit/core/engine/test_aio.py +++ b/tests/unit/core/engine/test_aio.py @@ -19,7 +19,7 @@ ) from clientwright.core.contracts.adapter import AdapterDeps from clientwright.core.errors import CircuitOpenError, DeadlineExceededError, TooManyRedirectsError -from clientwright.core.model import ResolvedTimeouts +from clientwright.core.model import ConnMetrics, ResolvedTimeouts from clientwright.core.policy.circuit import CircuitState from clientwright.core.testing import ManualClock from tests.helpers.engine import ( @@ -32,6 +32,7 @@ make_config, redirect_response, ) +from tests.helpers.telemetry import RecordingTracer from tests.helpers.views import FakeResponse # --- flow --- @@ -284,3 +285,28 @@ async def send(request: EngineRequest) -> FakeResponse: harness.engine.run(EngineRequest(), send), ) assert peak == 1 # POOL_LIMIT_PER_HOST is EMULATED: the engine serialized the origin + + +# --- connection metrics --- + + +async def test__conn_metrics__land_on_the_attempt_and_the_call_span() -> None: + tracer = RecordingTracer() + conn = ConnMetrics(dns=0.01, connect=0.02, pool_wait=0.003, reused=False, http_version="1.1") + harness = Harness(make_config(), tracer=tracer, conn=conn) + await harness.engine.run(EngineRequest(), as_async_send(ScriptedSend(FakeResponse(200)))) + attributes = tracer.spans[0].attributes + assert attributes["http.connection.dns_duration"] == 0.01 + assert attributes["http.connection.connect_duration"] == 0.02 + assert attributes["http.connection.pool_wait_duration"] == 0.003 + assert attributes["http.connection.reused"] is False + assert attributes["network.protocol.version"] == "1.1" + assert "http.connection.tls_duration" not in attributes # unset fields stay off the span + + +async def test__failed_attempt_without_response__no_conn_lookup() -> None: + tracer = RecordingTracer() + harness = Harness(make_config(retry=None), tracer=tracer, conn=ConnMetrics(dns=0.5)) + with pytest.raises(ConnectionError): + await harness.engine.run(EngineRequest(), as_async_send(ScriptedSend(ConnectionError("down")))) + assert "http.connection.dns_duration" not in tracer.spans[0].attributes diff --git a/tests/unit/core/engine/test_sync.py b/tests/unit/core/engine/test_sync.py index c6a1507..6caf6b7 100644 --- a/tests/unit/core/engine/test_sync.py +++ b/tests/unit/core/engine/test_sync.py @@ -15,6 +15,7 @@ from clientwright.core.config import CircuitBreakerConfig, PoolConfig, RedirectMode from clientwright.core.contracts.adapter import AdapterDeps from clientwright.core.errors import CircuitOpenError, DeadlineExceededError +from clientwright.core.model import ConnMetrics from tests.helpers.engine import ( EngineRequest, FixedDeadlineSource, @@ -25,6 +26,7 @@ make_config, redirect_response, ) +from tests.helpers.telemetry import RecordingTracer from tests.helpers.views import FakeResponse @@ -141,3 +143,14 @@ def send(request: EngineRequest) -> FakeResponse: for thread in threads: thread.join() assert peak == 1 + + +def test__conn_metrics__land_on_the_attempt_and_the_call_span() -> None: + tracer = RecordingTracer() + conn = ConnMetrics(dns=0.01, connect=0.02, reused=True) + harness = Harness(make_config(), sync=True, tracer=tracer, conn=conn) + harness.engine.run(EngineRequest(), as_sync_send(ScriptedSend(FakeResponse(200)))) + attributes = tracer.spans[0].attributes + assert attributes["http.connection.dns_duration"] == 0.01 + assert attributes["http.connection.connect_duration"] == 0.02 + assert attributes["http.connection.reused"] is True diff --git a/tests/unit/core/telemetry/test_emitter.py b/tests/unit/core/telemetry/test_emitter.py index f0bdb47..c4a3140 100644 --- a/tests/unit/core/telemetry/test_emitter.py +++ b/tests/unit/core/telemetry/test_emitter.py @@ -3,10 +3,11 @@ from __future__ import annotations from clientwright.core.config import ObservabilityConfig -from clientwright.core.model import Attempt, FailureKind, Outcome, RequestInfo +from clientwright.core.model import Attempt, ConnMetrics, FailureKind, Outcome, RequestInfo from clientwright.core.telemetry.emitter import ClientTelemetry, outcome_label, status_label from clientwright.core.telemetry.null import NullMetrics, NullTracer from clientwright.core.testing import RecordingMetrics +from tests.helpers.telemetry import RecordingTracer INFO = RequestInfo(method="GET", origin="https://a:443", url="https://a/u?token=x", route="/u") @@ -29,7 +30,8 @@ def test__call_lifecycle__records_call_attempts_and_balances_inflight() -> None: metrics = RecordingMetrics() emitter = telemetry(metrics) observation = emitter.call_start(INFO, started=0.0) - emitter.attempt_end(INFO, Attempt(index=1, started=0.0, duration=0.1, outcome=Outcome(kind=None, status_code=200))) + attempt = Attempt(index=1, started=0.0, duration=0.1, outcome=Outcome(kind=None, status_code=200)) + emitter.attempt_end(observation, INFO, attempt) emitter.call_end(observation, INFO, Outcome(kind=None, status_code=200), duration=0.2) assert metrics.inflight_balance == 0 assert metrics.calls[0]["status"] == "200" @@ -64,6 +66,42 @@ def test__metrics_disabled_in_config__nothing_recorded() -> None: assert metrics.inflight == [] +def test__attempt_with_conn_metrics__annotates_the_call_span() -> None: + tracer = RecordingTracer() + emitter = ClientTelemetry( + service="svc", + adapter="fake", + seam="test", + config=ObservabilityConfig(), + metrics=None, + tracer=tracer, + ) + observation = emitter.call_start(INFO, started=0.0) + conn = ConnMetrics(dns=0.01, pool_wait=0.002, reused=True, http_version="2") + emitter.attempt_end(observation, INFO, Attempt(1, 0.0, 0.1, Outcome(kind=None, status_code=200), conn=conn)) + attributes = tracer.spans[0].attributes + assert attributes["http.connection.dns_duration"] == 0.01 + assert attributes["http.connection.pool_wait_duration"] == 0.002 + assert attributes["http.connection.reused"] is True + assert attributes["network.protocol.version"] == "2" + assert "http.connection.connect_duration" not in attributes # an unseen phase is not a zero + + +def test__attempt_without_conn_metrics__leaves_the_span_alone() -> None: + tracer = RecordingTracer() + emitter = ClientTelemetry( + service="svc", + adapter="fake", + seam="test", + config=ObservabilityConfig(), + metrics=None, + tracer=tracer, + ) + observation = emitter.call_start(INFO, started=0.0) + emitter.attempt_end(observation, INFO, Attempt(1, 0.0, 0.1, Outcome(kind=None, status_code=200))) + assert set(tracer.spans[0].attributes) == {"http.request.method", "server.origin", "url.full"} + + def test__labels__helpers() -> None: assert outcome_label(Outcome(kind=None, status_code=200)) == "success" assert outcome_label(Outcome(kind=FailureKind.STATUS, status_code=503)) == "status" From e005cfe0ab2470c950063a03edf39185f635f9f1 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:24:14 +0300 Subject: [PATCH 2/5] fix(adapters): fill report.native_overrides with the accepted passthrough ``compile_plan`` has taken ``native_overrides`` since the first release and both the native-options and capabilities guides promise the report lists what was accepted per slot, but no adapter ever passed it, so ``handle.report.native_overrides`` was always ``{}``. Every adapter now hands its validated passthrough to ``compile_plan`` through a shared ``accepted_overrides`` helper: ``{slot: (key, ...)}``, keys sorted, slots the caller left empty omitted. --- clientwright/adapters/_httpx_shared.py | 9 +++++---- clientwright/adapters/aiohttp/adapter.py | 7 ++++--- clientwright/adapters/requests/adapter.py | 7 ++++--- clientwright/adapters/urllib3/adapter.py | 7 ++++--- clientwright/core/native.py | 7 ++++++- docs/agents.md | 5 ++--- tests/unit/adapters/aiohttp/test_build.py | 15 +++++++++++++++ tests/unit/adapters/httpx/test_units.py | 1 + tests/unit/adapters/requests/test_adapter.py | 1 + tests/unit/adapters/urllib3/test_adapter.py | 11 +++++++++++ tests/unit/core/test_capabilities.py | 10 +++++++++- 11 files changed, 62 insertions(+), 18 deletions(-) diff --git a/clientwright/adapters/_httpx_shared.py b/clientwright/adapters/_httpx_shared.py index 998335a..3fd968d 100644 --- a/clientwright/adapters/_httpx_shared.py +++ b/clientwright/adapters/_httpx_shared.py @@ -30,7 +30,7 @@ from ..core.engine.sync import SyncAttemptEngine from ..core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError from ..core.model import IDEMPOTENT_METHODS, ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of -from ..core.native import validate_native +from ..core.native import accepted_overrides, validate_native from ..core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle from ..core.policy.timeout import base_timeouts from ..core.telemetry.emitter import ClientTelemetry @@ -622,7 +622,7 @@ def _validated_native(self, config: ClientConfig, *, sync: bool) -> dict[str, di config_conflicts={}, ) - def _compile(self, config: ClientConfig, *, sync: bool) -> CallPlan: + def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]], *, sync: bool) -> CallPlan: applied = { Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, @@ -657,6 +657,7 @@ def _compile(self, config: ClientConfig, *, sync: bool) -> CallPlan: applied_natively=frozenset(applied), emulated=frozenset(emulated), dropped=dropped, + native_overrides=accepted_overrides(native), ) plan.report.enforce(config.on_unsupported) return plan @@ -699,7 +700,7 @@ def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[A runtime = deps.runtime or ClientRuntime.for_config( config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed ) - plan = self._compile(config, sync=False) + plan = self._compile(config, native, sync=False) base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS) engine = AsyncAttemptEngine( plan=plan, @@ -756,7 +757,7 @@ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[An runtime = deps.runtime or ClientRuntime.for_config( config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed ) - plan = self._compile(config, sync=True) + plan = self._compile(config, native, sync=True) base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS) engine = SyncAttemptEngine( plan=plan, diff --git a/clientwright/adapters/aiohttp/adapter.py b/clientwright/adapters/aiohttp/adapter.py index 4ad3028..70a4b44 100644 --- a/clientwright/adapters/aiohttp/adapter.py +++ b/clientwright/adapters/aiohttp/adapter.py @@ -24,7 +24,7 @@ from ...core.engine.aio import AsyncAttemptEngine from ...core.errors import UnsupportedCapabilityError from ...core.model import ResolvedTimeouts -from ...core.native import validate_native +from ...core.native import accepted_overrides, validate_native from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle from ...core.policy.timeout import base_timeouts from ...core.telemetry.emitter import ClientTelemetry @@ -116,7 +116,7 @@ def _validated_native(self, config: ClientConfig) -> dict[str, dict[str, Any]]: config_conflicts={}, ) - def _compile(self, config: ClientConfig) -> CallPlan: + def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan: applied = { Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, @@ -146,6 +146,7 @@ def _compile(self, config: ClientConfig) -> CallPlan: applied_natively=frozenset(applied), emulated=frozenset(emulated), dropped=dropped, + native_overrides=accepted_overrides(native), ) plan.report.enforce(config.on_unsupported) return plan @@ -179,7 +180,7 @@ def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[A runtime = deps.runtime or ClientRuntime.for_config( config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed ) - plan = self._compile(config) + plan = self._compile(config, native) base = base_timeouts(config.timeout, _NATIVE_TIMEOUT_DEFAULTS) engine = AsyncAttemptEngine( plan=plan, diff --git a/clientwright/adapters/requests/adapter.py b/clientwright/adapters/requests/adapter.py index 3603382..54f93ec 100644 --- a/clientwright/adapters/requests/adapter.py +++ b/clientwright/adapters/requests/adapter.py @@ -25,7 +25,7 @@ from ...core.engine.sync import SyncAttemptEngine from ...core.errors import UnsupportedCapabilityError from ...core.model import ResolvedTimeouts -from ...core.native import validate_native +from ...core.native import accepted_overrides, validate_native from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle from ...core.telemetry.emitter import ClientTelemetry from ._imports import HTTPAdapter, requests, urllib3 @@ -111,7 +111,7 @@ def _validated_native(self, config: ClientConfig) -> dict[str, dict[str, Any]]: config_conflicts={}, ) - def _compile(self, config: ClientConfig) -> CallPlan: + def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan: applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE} if resolve(config.pool.max_connections_per_host, None) is not None: applied.add(Capability.POOL_LIMIT_PER_HOST) @@ -136,6 +136,7 @@ def _compile(self, config: ClientConfig) -> CallPlan: applied_natively=frozenset(applied), emulated=frozenset(emulated), dropped=dropped, + native_overrides=accepted_overrides(native), ) plan.report.enforce(config.on_unsupported) return plan @@ -155,7 +156,7 @@ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[An runtime = deps.runtime or ClientRuntime.for_config( config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed ) - plan = self._compile(config) + plan = self._compile(config, native) engine = SyncAttemptEngine( plan=plan, runtime=runtime, diff --git a/clientwright/adapters/urllib3/adapter.py b/clientwright/adapters/urllib3/adapter.py index 6c5f11e..3409c52 100644 --- a/clientwright/adapters/urllib3/adapter.py +++ b/clientwright/adapters/urllib3/adapter.py @@ -24,7 +24,7 @@ from ...core.engine.sync import SyncAttemptEngine from ...core.errors import UnsupportedCapabilityError from ...core.model import ResolvedTimeouts -from ...core.native import validate_native +from ...core.native import accepted_overrides, validate_native from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle from ...core.policy.timeout import base_timeouts from ...core.telemetry.emitter import ClientTelemetry @@ -141,7 +141,7 @@ def _validated_native(self, config: ClientConfig) -> dict[str, dict[str, Any]]: config_conflicts={}, ) - def _compile(self, config: ClientConfig) -> CallPlan: + def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan: per_host = resolve(config.pool.max_connections_per_host, None) applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE} if per_host is not None: @@ -172,6 +172,7 @@ def _compile(self, config: ClientConfig) -> CallPlan: applied_natively=frozenset(applied), emulated=frozenset(emulated), dropped=dropped, + native_overrides=accepted_overrides(native), ) plan.report.enforce(config.on_unsupported) return plan @@ -215,7 +216,7 @@ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[An runtime = deps.runtime or ClientRuntime.for_config( config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed ) - plan = self._compile(config) + plan = self._compile(config, native) engine = SyncAttemptEngine( plan=plan, runtime=runtime, diff --git a/clientwright/core/native.py b/clientwright/core/native.py index cb9df31..745c1f3 100644 --- a/clientwright/core/native.py +++ b/clientwright/core/native.py @@ -81,4 +81,9 @@ def validate_native( return validated -__all__ = ["validate_native"] +def accepted_overrides(validated: Mapping[str, Mapping[str, object]]) -> dict[str, tuple[str, ...]]: + """Report shape of validated passthrough: slot -> the keys that survived validation.""" + return {slot: tuple(sorted(values)) for slot, values in validated.items() if values} + + +__all__ = ["accepted_overrides", "validate_native"] diff --git a/docs/agents.md b/docs/agents.md index 5bb30a2..2122f45 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -306,9 +306,8 @@ and `redirects="natvie"` raises `ValueError` instead of silently doing nothing. `collapses`, `notes`, `.support_of(capability)`. `ConfigApplicationReport`: `adapter`, `applied_natively`, `emulated`, `dropped`, `dead_retryable_kinds`, `collapsed_kinds`, `native_overrides`, `.has_issues`, `.issues()`, -`.enforce(policy)`. `native_overrides` is part of the record's shape but no shipped adapter -fills it in — read the accepted passthrough off your own `NativeOptions`, not off the -report. +`.enforce(policy)`. `native_overrides` is `{slot: (accepted key, ...)}` — the passthrough +that survived validation, per slot, keys sorted; a slot you passed nothing into is absent. ### Per-call options diff --git a/tests/unit/adapters/aiohttp/test_build.py b/tests/unit/adapters/aiohttp/test_build.py index e329cf2..b3285f8 100644 --- a/tests/unit/adapters/aiohttp/test_build.py +++ b/tests/unit/adapters/aiohttp/test_build.py @@ -16,6 +16,7 @@ from clientwright.core.capabilities import Capability # noqa: E402 from clientwright.core.config import ( # noqa: E402 ClientConfig, + NativeOptions, PoolConfig, ProxyConfig, TimeoutConfig, @@ -113,6 +114,20 @@ async def test__per_host_limit__applied_natively_on_the_connector() -> None: await handle.aclose() +async def test__native_passthrough__applied_and_listed_in_the_report() -> None: + config = ClientConfig( + service_name="s", + native=NativeOptions.of(session={"auto_decompress": False}, connector={"use_dns_cache": False}), + ) + handle = clientwright.build_handle("aiohttp", config) + try: + assert handle.report.native_overrides == {"session": ("auto_decompress",), "connector": ("use_dns_cache",)} + assert handle.client.connector.use_dns_cache is False + finally: + assert handle.aclose is not None + await handle.aclose() + + async def test__proxy__reported_emulated() -> None: config = ClientConfig(service_name="s", proxy=ProxyConfig(url="http://proxy.local:3128")) handle = clientwright.build_handle("aiohttp", config) diff --git a/tests/unit/adapters/httpx/test_units.py b/tests/unit/adapters/httpx/test_units.py index 8bb0e07..cb49406 100644 --- a/tests/unit/adapters/httpx/test_units.py +++ b/tests/unit/adapters/httpx/test_units.py @@ -203,6 +203,7 @@ def test__legit_native_options__accepted() -> None: ) handle = clientwright.build_handle("httpx", config) assert type(handle.client) is httpx.AsyncClient + assert handle.report.native_overrides == {"client": ("trust_env",), "transport": ("local_address",)} # --- TLS wiring -------------------------------------------------------------- diff --git a/tests/unit/adapters/requests/test_adapter.py b/tests/unit/adapters/requests/test_adapter.py index e0377c8..143d88b 100644 --- a/tests/unit/adapters/requests/test_adapter.py +++ b/tests/unit/adapters/requests/test_adapter.py @@ -147,6 +147,7 @@ def test__native_session_attributes__applied_to_the_built_session() -> None: handle = clientwright.build_sync_handle("requests", config) try: assert handle.client.trust_env is False + assert handle.report.native_overrides == {"session": ("trust_env",)} finally: assert handle.close is not None handle.close() diff --git a/tests/unit/adapters/urllib3/test_adapter.py b/tests/unit/adapters/urllib3/test_adapter.py index 4bb012a..dbc6a89 100644 --- a/tests/unit/adapters/urllib3/test_adapter.py +++ b/tests/unit/adapters/urllib3/test_adapter.py @@ -17,6 +17,7 @@ from clientwright.core.capabilities import Capability # noqa: E402 from clientwright.core.config import ( # noqa: E402 ClientConfig, + NativeOptions, PoolConfig, ProxyConfig, TimeoutConfig, @@ -78,6 +79,16 @@ def test__non_blocking_pool__never_passes_a_pool_timeout(monkeypatch: pytest.Mon # --- capability reporting ---------------------------------------------------- +def test__native_passthrough__listed_in_the_report() -> None: + config = ClientConfig(service_name="s", native=NativeOptions.of(manager={"strict": True})) + handle = clientwright.build_sync_handle("urllib3", config) + try: + assert handle.report.native_overrides == {"manager": ("strict",)} + finally: + assert handle.close is not None + handle.close() + + def test__blocking_pool__pool_knobs_reported_applied_natively() -> None: config = ClientConfig( service_name="s", diff --git a/tests/unit/core/test_capabilities.py b/tests/unit/core/test_capabilities.py index 93116fa..1b82b38 100644 --- a/tests/unit/core/test_capabilities.py +++ b/tests/unit/core/test_capabilities.py @@ -22,7 +22,7 @@ UnsupportedCapabilityError, ) from clientwright.core.model import FailureKind -from clientwright.core.native import validate_native +from clientwright.core.native import accepted_overrides, validate_native def _target(*, timeout: float = 1.0, limits: object = None) -> None: @@ -89,6 +89,14 @@ def test__conflict_with_explicit_config__raises() -> None: ) +# --- accepted_overrides --- + + +def test__accepted_overrides__lists_sorted_keys_per_slot_and_skips_empty_slots() -> None: + validated = validate(NativeOptions.of(client={"timeout": 5.0, "limits": None}, transport={})) + assert accepted_overrides(validated) == {"client": ("limits", "timeout")} + + CAPS = AdapterCapabilities( adapter="fake", seam="test", From f35a5b785cb0d0dcab3d9b14e7255620f41562e9 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:25:31 +0300 Subject: [PATCH 3/5] feat: export redact_headers from the package root ``DEFAULT_SENSITIVE_HEADERS`` is a root export with no config knob behind it - headers never reach a log line or a span - so its whole purpose is the recipe in the masking guide. The other half of that recipe, ``redact_headers``, lived at ``clientwright.core.telemetry.redaction`` and was not exported, which made the public constant point into a private module. ``redact_headers`` is now a root export; the guide and the agents page use it. --- clientwright/__init__.py | 2 ++ docs/agents.md | 8 ++++---- docs/guide/masking.md | 7 ++++--- tests/unit/core/telemetry/test_redaction.py | 7 +++++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/clientwright/__init__.py b/clientwright/__init__.py index eaa39fa..44e289b 100644 --- a/clientwright/__init__.py +++ b/clientwright/__init__.py @@ -74,6 +74,7 @@ from .core.options import CallOptions, call_options, current_call_options from .core.plan import ClientHandle, ClientRuntime, inspect_client from .core.registry import register_adapter, registered_adapters, resolve_adapter +from .core.telemetry.redaction import redact_headers # The builders are typed ``Any`` deliberately. The whole product is "you get the # REAL native client", and the core cannot name ``httpx.AsyncClient`` without @@ -175,6 +176,7 @@ def build_sync(adapter: str, config: ClientConfig, deps: AdapterDeps | None = No "inspect", "inspect_client", "is_set", + "redact_headers", "register_adapter", "registered_adapters", "resolve_adapter", diff --git a/docs/agents.md b/docs/agents.md index 2122f45..b4f6574 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -276,10 +276,10 @@ remaining deadline → a token in the origin's budget. Refusals at the last four | `ProxyConfig` | `url=None`, `from_env=False` — mutually exclusive, `ValueError` if both are given | | `NativeOptions` | `NativeOptions.of(slot={...})`; `slots` is `{slot_name: {kwarg: value}}` | -`DEFAULT_SENSITIVE_HEADERS` is exported but is not a config knob: clientwright never emits -headers into a log line or a span, so there is nothing for it to protect here. It exists for -services that log headers themselves, with -`clientwright.core.telemetry.redaction.redact_headers`. +`DEFAULT_SENSITIVE_HEADERS` is not a config knob: clientwright never emits headers into a +log line or a span, so there is nothing for it to protect here. It exists for services that +log headers themselves, and pairs with `redact_headers(headers, sensitive)` — both are root +exports. ### Data model and enums diff --git a/docs/guide/masking.md b/docs/guide/masking.md index ead1de6..6317af3 100644 --- a/docs/guide/masking.md +++ b/docs/guide/masking.md @@ -114,11 +114,12 @@ call rate) — not in every HTTP client. clientwright never writes request or response headers into logs or spans — that firehose is excluded by design, which is why there is no header knob on -`ObservabilityConfig`. If your *own* code logs headers, the toolkit is public: +`ObservabilityConfig`. `DEFAULT_SENSITIVE_HEADERS` is therefore not a config +knob either: it is a default list for *your* code, paired with the redactor the +emitter uses on URLs. Both are exported from the root: ```python -from clientwright.core.config import DEFAULT_SENSITIVE_HEADERS -from clientwright.core.telemetry.redaction import redact_headers +from clientwright import DEFAULT_SENSITIVE_HEADERS, redact_headers safe = redact_headers(response.headers, DEFAULT_SENSITIVE_HEADERS) ``` diff --git a/tests/unit/core/telemetry/test_redaction.py b/tests/unit/core/telemetry/test_redaction.py index d5616b5..d5be659 100644 --- a/tests/unit/core/telemetry/test_redaction.py +++ b/tests/unit/core/telemetry/test_redaction.py @@ -2,6 +2,7 @@ from __future__ import annotations +import clientwright from clientwright.core.telemetry.redaction import REDACTED, redact_headers, redact_url @@ -15,3 +16,9 @@ def test__sensitive_query_params__masked() -> None: url = redact_url("https://a/x?token=secret&page=2", frozenset({"token"})) assert "secret" not in url assert "page=2" in url + + +def test__root_export__pairs_the_default_list_with_its_redactor() -> None: + assert clientwright.redact_headers is redact_headers + safe = clientwright.redact_headers({"Cookie": "s=1"}, clientwright.DEFAULT_SENSITIVE_HEADERS) + assert safe["Cookie"] == REDACTED From 7d7264e6e38e2ed19469c97e15e9a62612e98c86 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:30:07 +0300 Subject: [PATCH 4/5] fix(retry): let the method gate refuse, not only permit The gate was ``method not in retry.methods and not info.idempotent``: an OR of two permissions, so neither half could ever say no. * ``idempotent=False`` on a GET was a silent no-op - the method was still in ``retry.methods``, so the call site's veto never reached the decision, though the per-call guide promises it does. * ``RetryConfig.methods`` could not narrow at all. Every adapter derives ``RequestInfo.idempotent`` from ``IDEMPOTENT_METHODS``, so a DELETE arrives with ``idempotent=True`` and was retried even after the operator took DELETE out of ``methods`` - which the agents page states as the way to stop it. ``RequestInfo.idempotent`` restates the method's RFC default unless the call site overrode it, so a flag that disagrees with ``IDEMPOTENT_METHODS`` is the call site talking and decides; one that only restates the default leaves the decision with ``retry.methods``. POST + ``idempotent=True`` and a widened ``methods`` list keep working exactly as before. Behaviour changes only where a caller asked for it and was ignored: an explicit ``idempotent=False``, or a customised ``retry.methods``. A default config is unaffected. Refusals still count as ``retry_skipped{reason="method"}``. --- clientwright/core/policy/retry.py | 18 ++++++++++-- docs/agents.md | 8 +++--- docs/guide/per-call-options.md | 4 ++- docs/guide/retries.md | 6 +++- tests/integration/parity/test_semantics.py | 33 ++++++++++++++++++++++ tests/unit/core/policy/test_retry.py | 31 ++++++++++++++++++++ 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/clientwright/core/policy/retry.py b/clientwright/core/policy/retry.py index 215df6b..a35f012 100644 --- a/clientwright/core/policy/retry.py +++ b/clientwright/core/policy/retry.py @@ -11,7 +11,7 @@ from random import Random from ..config import RetryConfig -from ..model import Attempt, FailureKind, RequestInfo +from ..model import IDEMPOTENT_METHODS, Attempt, FailureKind, RequestInfo # A retry whose backoff would land this close to the deadline is pointless. _DEADLINE_SLACK = 0.001 @@ -40,6 +40,20 @@ def _wants_retry(self, attempt: Attempt) -> str | None: return f"kind_{kind.value}" return None + def _method_allows_retry(self, info: RequestInfo) -> bool: + """Two vetoes on repeating this method, either of which is enough. + + ``RequestInfo.idempotent`` restates the method's RFC default unless the + CALL SITE overrode it, so a flag disagreeing with ``IDEMPOTENT_METHODS`` + is the call site talking and decides on its own - that is what makes + ``idempotent=True`` unlock a POST and ``idempotent=False`` veto a GET. + A flag that only restates the default leaves the decision with the + operator's ``retry.methods``. + """ + if info.idempotent != (info.method in IDEMPOTENT_METHODS): + return info.idempotent + return info.method in self._config.methods + def _backoff(self, attempt_index: int, retry_after: float | None, rng: Random) -> float: config = self._config if config.respect_retry_after and retry_after is not None: @@ -67,7 +81,7 @@ def decide( return RetryDecision(retry=False, reason="final") if len(history) >= config.max_attempts: return RetryDecision(retry=False, reason="attempts") - if info.method not in config.methods and not info.idempotent: + if not self._method_allows_retry(info): return RetryDecision(retry=False, reason="method") if config.require_replayable_body and not replayable: return RetryDecision(retry=False, reason="non_replayable") diff --git a/docs/agents.md b/docs/agents.md index b4f6574..404baa2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -457,10 +457,10 @@ What each one will not do: (`retry_skipped{reason="non_replayable"}`), never an exception — you get the failed response, not an error. 9. **The engine will not retry a `POST` on its own.** Pass `idempotent=True` at the call - site — the extension for httpx, `call_options` elsewhere — and mean it. The reverse is - *not* symmetric: `idempotent=False` on a `GET` does not stop a retry, because the method - gate refuses only when the method is outside `retry.methods` **and** the flag is false. - To stop retrying a method, remove it from `RetryConfig.methods`. + site — the extension for httpx, `call_options` elsewhere — and mean it. It is symmetric: + `idempotent=False` on a `GET` stops the retry. The flag decides only when it contradicts + the method's RFC default (`IDEMPOTENT_METHODS`); when it merely restates it, the gate is + `RetryConfig.methods`, which is how you stop retrying a method client-wide. 10. **The total deadline covers everything and is only hard on async.** Async engines wrap each attempt in a cancellation scope; sync engines cannot cancel a blocked socket, so they clamp phases and re-check at attempt boundaries — the failure then arrives as diff --git a/docs/guide/per-call-options.md b/docs/guide/per-call-options.md index f0c628d..04b50ca 100644 --- a/docs/guide/per-call-options.md +++ b/docs/guide/per-call-options.md @@ -87,7 +87,9 @@ URL. for a non-idempotent method. It is a statement about *your* semantics ("repeating this request is safe"), not a request for more aggressive retrying — all other gates (attempts, body replayability, deadline, budget) still apply. `False` works -in the other direction: it forbids retrying a normally-idempotent method. +in the other direction: it forbids retrying a normally-idempotent method — the +`GET` that triggers a report run, the `DELETE` your upstream is not really +idempotent about. !!! warning "Say it truthfully" diff --git a/docs/guide/retries.md b/docs/guide/retries.md index aaa7d06..8f1d979 100644 --- a/docs/guide/retries.md +++ b/docs/guide/retries.md @@ -55,7 +55,11 @@ A retry-worthy failure is necessary but not sufficient. In order: 2. **Idempotency.** `GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`, `TRACE` pass by method. A `POST` is refused — unless the *call site* vouches for it via the [per-call idempotency flag](per-call-options.md), which is the honest place for - that knowledge to live. + that knowledge to live. The flag speaks in both directions: `idempotent=False` + refuses a method the table would have allowed. Narrowing for the whole client + is `RetryConfig.methods` — a method outside that set is not retried, and a call + site can only override it by contradicting the method's default, never by + restating it. 3. **Replayable body.** Before the first send the engine freezes the request body (buffers a stream, if there is one). A body that cannot be replayed — a one-shot generator, an open socket — vetoes every repeat. No half-sent uploads, ever. diff --git a/tests/integration/parity/test_semantics.py b/tests/integration/parity/test_semantics.py index 2469840..61581c2 100644 --- a/tests/integration/parity/test_semantics.py +++ b/tests/integration/parity/test_semantics.py @@ -144,6 +144,39 @@ def test__post_idempotency_gate__same_on_every_adapter_sync(adapter_name: str, o driver.close(client) +# --- GET: the call-site veto stops a retry the method would have allowed ----- + + +@pytest.mark.parametrize("adapter_name", adapter_params(ASYNC_ADAPTERS)) +async def test__get_idempotency_veto__same_on_every_adapter(adapter_name: str, origin: OriginServer) -> None: + driver = get_driver(adapter_name) + metrics, deps = fresh_deps() + client = driver.build(battery_config(driver, origin, retry=FAST_RETRY), deps) + path = flaky_path(adapter_name, 1) + try: + response = await driver.request(client, origin.url, "GET", path, idempotent=False) + finally: + await driver.close(client) + assert response.status == 503 # the call site said this GET must not be repeated + assert origin.request_count(path) == 1 + assert [record["reason"] for record in metrics.retry_skips] == ["method"] + + +@pytest.mark.parametrize("adapter_name", adapter_params(SYNC_ADAPTERS)) +def test__get_idempotency_veto__same_on_every_adapter_sync(adapter_name: str, origin: OriginServer) -> None: + driver = get_driver(adapter_name) + metrics, deps = fresh_deps() + client = driver.build(battery_config(driver, origin, retry=FAST_RETRY), deps) + path = flaky_path(adapter_name, 1) + try: + response = driver.request(client, origin.url, "GET", path, idempotent=False) + finally: + driver.close(client) + assert response.status == 503 + assert origin.request_count(path) == 1 + assert [record["reason"] for record in metrics.retry_skips] == ["method"] + + # --- circuit breaker: one 5xx signal, local rejection, origin untouched ------ diff --git a/tests/unit/core/policy/test_retry.py b/tests/unit/core/policy/test_retry.py index 8c59dff..9e7827e 100644 --- a/tests/unit/core/policy/test_retry.py +++ b/tests/unit/core/policy/test_retry.py @@ -11,6 +11,9 @@ INFO_GET = RequestInfo(method="GET", origin="https://a:443", url="https://a/u") INFO_POST = RequestInfo(method="POST", origin="https://a:443", url="https://a/u", idempotent=False) INFO_POST_IDEMPOTENT = RequestInfo(method="POST", origin="https://a:443", url="https://a/u", idempotent=True) +# What every adapter puts in RequestInfo when the call site vetoes a method the RFC calls safe. +INFO_GET_VETOED = RequestInfo(method="GET", origin="https://a:443", url="https://a/u", idempotent=False) +INFO_DELETE = RequestInfo(method="DELETE", origin="https://a:443", url="https://a/u", idempotent=True) def attempt(outcome: Outcome, index: int = 1) -> Attempt: @@ -83,6 +86,34 @@ def test__post_with_idempotency_flag__allowed() -> None: assert decision.retry +def test__get_with_idempotent_false__denied_by_method() -> None: + policy = DefaultRetryPolicy(RetryConfig(jitter=0.0)) + decision = decide(policy, history(Outcome(kind=FailureKind.READ_TIMEOUT)), info=INFO_GET_VETOED) + assert not decision.retry + assert decision.reason == "method" # the flag vetoes in both directions + + +def test__method_removed_from_config__denied_although_the_method_is_idempotent() -> None: + policy = DefaultRetryPolicy(RetryConfig(methods=frozenset({"GET"}))) + decision = decide(policy, history(Outcome(kind=FailureKind.READ_TIMEOUT)), info=INFO_DELETE) + assert not decision.retry + assert decision.reason == "method" + + +def test__post_added_to_config_methods__retried_without_a_call_site_flag() -> None: + policy = DefaultRetryPolicy(RetryConfig(jitter=0.0, methods=frozenset({"GET", "POST"}))) + decision = decide(policy, history(Outcome(kind=FailureKind.CONNECT_TIMEOUT)), info=INFO_POST) + assert decision.retry # the operator widened the list; no call site had to vouch + + +def test__call_site_flag__overrides_the_config_only_when_it_contradicts_the_method() -> None: + policy = DefaultRetryPolicy(RetryConfig(jitter=0.0, methods=frozenset({"HEAD"}))) + vouched = decide(policy, history(Outcome(kind=FailureKind.CONNECT_TIMEOUT)), info=INFO_POST_IDEMPOTENT) + assert vouched.retry # "this POST is safe" is knowledge the config cannot have + restated = decide(policy, history(Outcome(kind=FailureKind.CONNECT_TIMEOUT)), info=INFO_GET) + assert not restated.retry # a flag that only restates GET's default leaves the config in charge + + def test__non_replayable_body__denied() -> None: policy = DefaultRetryPolicy(RetryConfig()) decision = decide(policy, history(Outcome(kind=FailureKind.READ_TIMEOUT)), replayable=False) From ca1ce2f2a2869711534bcb038df50e064f288428 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:31:01 +0300 Subject: [PATCH 5/5] docs: say that the engine never raises NotReplayableError The class docstring read like an error you catch. It is not raised anywhere: a non-replayable body ends the call with the response it already has plus a ``retry_skipped{reason="non_replayable"}`` counter, which ``test__non_replayable_body__skip_sentinel_instead_of_retry`` has always pinned. The docstring and the retry gate list now say so, so nobody writes an ``except NotReplayableError`` that can never fire. --- clientwright/core/errors.py | 10 +++++++++- docs/guide/retries.md | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/clientwright/core/errors.py b/clientwright/core/errors.py index 3fb9903..6068f19 100644 --- a/clientwright/core/errors.py +++ b/clientwright/core/errors.py @@ -93,7 +93,15 @@ def __init__(self, hops: int) -> None: class NotReplayableError(CallError): - """The request body cannot be replayed, so the required repeat is impossible.""" + """A body that cannot be replayed made a required repeat impossible. + + The engine never raises this. A non-replayable body ends the call with the + response it already has plus a ``retry_skipped{reason="non_replayable"}`` + counter, so ``except NotReplayableError`` around a call never fires. It is + part of the public ``CallError`` family for adapters and callers that choose + to make that refusal fatal themselves; the adapter translators pass it + through unchanged rather than dressing it in an SDK error class. + """ __all__ = [ diff --git a/docs/guide/retries.md b/docs/guide/retries.md index 8f1d979..f433e74 100644 --- a/docs/guide/retries.md +++ b/docs/guide/retries.md @@ -63,6 +63,9 @@ A retry-worthy failure is necessary but not sufficient. In order: 3. **Replayable body.** Before the first send the engine freezes the request body (buffers a stream, if there is one). A body that cannot be replayed — a one-shot generator, an open socket — vetoes every repeat. No half-sent uploads, ever. + The veto is a counter and the response you already have, never an exception: + `NotReplayableError` is exported for code that wants to make it fatal itself, + and the engine does not raise it. 4. **The deadline.** A backoff sleep that would land past the remaining total is pointless; the engine returns the failure now instead of burning the budget. 5. **The retry budget.** See below.