Fix the defects the agents page turned up - #20
Merged
Conversation
``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``.
…ough
``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.
``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.
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"}``.
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.
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five defects that surfaced while writing
docs/agents.mdagainst the source. Each onereproduced first; the order below is the order they were fixed — the ones that change what
a caller gets first, the reader-only ones last.
1. The retry method gate could only permit, never refuse
Wrong. The gate was
info.method not in config.methods and not info.idempotent— an ORof two permissions, so neither half could say no. Two documented behaviours were dead:
idempotent=Falseon aGETwas a silent no-op.GETis inretry.methods, so thecall site's veto never reached the decision, while the per-call guide said "
Falseworksin the other direction: it forbids retrying a normally-idempotent method".
RetryConfig.methodscould not narrow at all. Every adapter derivesRequestInfo.idempotentfromIDEMPOTENT_METHODS, so aDELETEarrives withidempotent=Trueand was retried even after the operator tookDELETEout ofmethods— which
docs/agents.mdrule 9 states as the way to stop retrying a method.How I know. Against
master, with the values the shipped adapters actually put inRequestInfo:Only the first line is correct. Two independent docs claim narrowing on two different axes
and one expression breaks both, so I read this as the code drifting, not the docs.
Changed.
RequestInfo.idempotentrestates the method's RFC default unless the call siteoverrode it, so a flag that disagrees with
IDEMPOTENT_METHODSis the call site talkingand decides on its own; a flag that only restates the default leaves the decision with the
operator's
retry.methods.POST+idempotent=Trueand a widenedmethodslist behaveexactly as before, and the refusal still counts as
retry_skipped{reason="method"}.Tests: four policy cases in
tests/unit/core/policy/test_retry.py(three of them fail onmaster) plus a parity pair that drives a vetoedGETthrough all five adapters againstthe real origin and asserts one request and one
methodskip.Not breaking as an API, but it does change behaviour where a caller asked for something
and was ignored: an explicit
idempotent=False, or a customisedretry.methods(note thata list like
{"GET", "POST"}now really means only those two). A default config with noper-call flags is unaffected. That is the whole point of the change, so it is a
fix:andnot a
feat!:— worth a line in the release notes all the same.2.
report.native_overrideswas always emptyWrong.
compile_planhas acceptednative_overridessince the first release, and bothdocs/guide/native-options.mdanddocs/guide/capabilities.mdpromise the report lists theaccepted passthrough per slot — but no adapter ever passed it.
How I know. Building a
requestsclient withNativeOptions.of(session={"trust_env": False}): the option is applied(
session.trust_env is False) andhandle.report.native_overrides == {}.Changed. A shared
accepted_overrideshelper next tovalidate_native; every adapternow hands its validated passthrough to
compile_plan. Shape:{slot: (key, ...)}, keyssorted, slots the caller left empty omitted. Covered per adapter family and in
tests/unit/core/test_capabilities.py. Not breaking — a field that was always{}startscarrying what it always advertised.
3.
conn_metricshad no consumerWrong.
conn_metricsis on both normalizer protocols and implemented by every adapter —aiohttp does real work for it through
TraceConfig— but neither engine called it, soAttempt.connwas alwaysNoneand nothing surfaced the timings, whileaiohttp/capabilities.pydeclaresCONN_METRICS: native.How I know. No
conn_metricscall anywhere incore/engine/, no.connread in theemitter, and no
raise/read of the value outside the aiohttp trace tests.Changed. Both engines fill
Attempt.connfrom the normalizer, and the emitter hangswhat the adapter saw on the call span:
http.connection.dns_duration,...connect_duration,...tls_duration,...pool_wait_duration,...reusedandnetwork.protocol.version. A phase the adapter cannot observe stays off the span ratherthan being reported as a zero, and on a retried call the last attempt that saw them wins.
The metric families are a frozen contract and are untouched — that is why the span, not a
new histogram, is the surface.
ClientTelemetry.attempt_endnow takes the observationfirst, like
call_end; it is called only by the two engines.Covered in both engine suites, in the emitter suite, and end to end in
tests/integration/adapters/test_aiohttp.py: two calls to the same origin, the firstshowing a connect duration and
reused: False, the secondreused: Trueand no connectduration. Not breaking.
4.
NotReplayableErroris exported but never raisedNot a code defect — the docstring was the defect. The engine deliberately ends a call
with the response it already has plus
retry_skipped{reason="non_replayable"}; that hasalways been pinned by
test__non_replayable_body__skip_sentinel_instead_of_retry. Removingthe export would break
exceptclauses that compile today, and turning the refusal into anexception would be a much worse change than the one it fixes.
The class docstring read like an error you catch. It now says the engine never raises it,
and the retry gate list in the guide says the veto is a counter, never an exception — so
nobody writes an
except NotReplayableErrorthat can never fire.docs/agents.mdalreadyhad this right and is unchanged.
5.
DEFAULT_SENSITIVE_HEADERSpointed into a private moduleWrong. The constant is a root export with no config knob behind it (the knob was dropped
in 0.2.0; headers never reach logs or spans by design), so its entire purpose is the recipe
in the masking guide — and the other half of that recipe,
redact_headers, was onlyimportable from
clientwright.core.telemetry.redaction.Changed.
redact_headersis now a root export; the masking guide and the agents page useit. Additive, so
feat:rather thanfix:.redact_urlstays where it is: its companionconstant
DEFAULT_SENSITIVE_QUERY_PARAMSdoes have a config knob(
ObservabilityConfig.sensitive_query_params), so it is not orphaned in the same way — saythe word and I will export it too for symmetry.
Checks
uv sync --frozen --all-extrasthroughout;uv.lockis unchanged.One thing I did not touch
docs/adapters/aiohttp.mdsays the body read duration is reported ashttp_client_body_duration_seconds"measured via the trace hooks", whiledocs/agents.mdsays that metric is httpx-family only and the aiohttp adapter never callswrap_stream. One of the two pages is wrong about the aiohttp body metric; it is outsidethis round's findings, so I left it alone.