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
2 changes: 2 additions & 0 deletions clientwright/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions clientwright/adapters/_httpx_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions clientwright/adapters/aiohttp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions clientwright/adapters/requests/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions clientwright/adapters/urllib3/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion clientwright/core/engine/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion clientwright/core/engine/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion clientwright/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand Down
7 changes: 6 additions & 1 deletion clientwright/core/native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
18 changes: 16 additions & 2 deletions clientwright/core/policy/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
23 changes: 21 additions & 2 deletions clientwright/core/telemetry/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/adapters/aiohttp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
31 changes: 19 additions & 12 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -374,7 +373,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)`.

Expand Down Expand Up @@ -450,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
Expand Down
7 changes: 4 additions & 3 deletions docs/guide/masking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Loading