clientwright 0.1.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clientwright-0.2.1/CHANGELOG.md +35 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/PKG-INFO +10 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/README.md +9 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/__init__.py +4 -0
- clientwright-0.2.1/clientwright/__version__.py +1 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/_httpx_shared.py +5 -4
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/adapter.py +4 -3
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/adapter.py +4 -3
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/adapter.py +4 -3
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/config.py +21 -3
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/__init__.py +2 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/observability.py +19 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/aio.py +2 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/sync.py +2 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/errors.py +9 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/native.py +6 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/retry.py +16 -2
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/emitter.py +36 -4
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/engine.py +11 -7
- clientwright-0.2.1/tests/helpers/telemetry.py +41 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_aiohttp.py +19 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/parity/test_semantics.py +33 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_build.py +15 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/test_units.py +1 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_adapter.py +1 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_adapter.py +11 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_aio.py +27 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_sync.py +13 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry.py +31 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_emitter.py +40 -2
- clientwright-0.2.1/tests/unit/core/telemetry/test_masking.py +140 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction.py +7 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities.py +9 -1
- clientwright-0.1.0/CHANGELOG.md +0 -17
- clientwright-0.1.0/clientwright/__version__.py +0 -1
- {clientwright-0.1.0 → clientwright-0.2.1}/.gitignore +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/LICENSE +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/_lazy.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/_imports.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/capabilities.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/classify.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/errors.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/middleware.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/options.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/trace.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/views.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/_imports.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/adapter.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/capabilities.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/classify.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/errors.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize_sync.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/transport.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/views.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/_imports.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/adapter.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/capabilities.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/classify.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/errors.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize_sync.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/transport.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/views.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/prometheus.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/otel.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/_imports.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/capabilities.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/classify.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/errors.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/views.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/_imports.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/capabilities.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/classify.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/errors.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/views.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/contrib/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/contrib/deadline.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/contrib/dishka.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/balancer/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/balancer/policy.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/capabilities.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/adapter.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/context.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/message.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/settings.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/base.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/redirects.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/suppress.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/model.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/options.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/plan.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/budget.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/circuit.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/concurrency.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/timeout.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/registry.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/names.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/null.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/redaction.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/testing/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/testing/doubles.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/testing/origin.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/py.typed +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/pyproject.toml +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/conftest.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/drivers.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/views.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx2.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_async.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_sync.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_requests.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_urllib3.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/chaos/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/chaos/test_faults.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/chaos/test_origin_faults.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/conftest.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/contrib/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/contrib/test_deadline.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/contrib/test_dishka.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/parity/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/conftest.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/test_public_endpoints.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/test_tls_and_dns.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_trace.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_units.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/conftest.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/test_units.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_exports.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_otel.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_prometheus.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_units.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/test_bare_install.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_family_parity.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_shared.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_normalize.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_units.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline_context.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/test_dishka.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_base.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_redirects.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_suppress.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_budget.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit_properties.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_concurrency.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry_properties.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout_properties.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/__init__.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction_properties.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_balancer.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities_properties.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_config.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_enum_coercion.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_errors.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_model.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_options.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_plan.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_registry.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_settings.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/test_docs_snippets.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/test_sdk_imports_are_guarded.py +0 -0
- {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/test_version.py +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.1](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.2.0...clientwright-v0.2.1) (2026-09-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* retry gating, native override reporting and connection metrics ([#21](https://github.com/bedrock-python/clientwright/issues/21)) ([b82e2dd](https://github.com/bedrock-python/clientwright/commit/b82e2dd886fd049f1f5d4ae8c169bdabe599d585))
|
|
9
|
+
|
|
10
|
+
## [0.2.0](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.1.0...clientwright-v0.2.0) (2026-08-14)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### ⚠ BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* add url_masker seam for value-level PII masking, drop dead sensitive_headers knob
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* add url_masker seam for value-level PII masking, drop dead sensitive_headers knob ([90f74fb](https://github.com/bedrock-python/clientwright/commit/90f74fbecaafb77b2405c4d06804e3d7dad91b34))
|
|
20
|
+
|
|
21
|
+
## 0.1.0 (2026-08-14)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* initial release ([14d4ee6](https://github.com/bedrock-python/clientwright/commit/14d4ee6eba85e6bfb5445b75393891f45cd9878d))
|
|
27
|
+
|
|
28
|
+
## Changelog
|
|
29
|
+
|
|
30
|
+
All notable changes to this project are documented in this file.
|
|
31
|
+
|
|
32
|
+
This file is generated by [Release Please](https://github.com/googleapis/release-please)
|
|
33
|
+
from [Conventional Commits](https://www.conventionalcommits.org/) — do not edit it by hand.
|
|
34
|
+
The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); while the
|
|
35
|
+
version is `0.x`, minor releases may contain breaking changes.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: clientwright
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: HTTP client platform: one resilience and observability core with adapters for httpx, aiohttp, requests, urllib3 and friends
|
|
5
5
|
Project-URL: Repository, https://github.com/bedrock-python/clientwright
|
|
6
6
|
Project-URL: Documentation, https://bedrock-python.github.io/clientwright/
|
|
@@ -95,6 +95,15 @@ handle = inspect(client) # what actually got applied
|
|
|
95
95
|
print(handle.report.dropped) # config the adapter could not express
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
> [!TIP]
|
|
99
|
+
> **Building this with an AI assistant?** Hand it
|
|
100
|
+
> **[one page](https://bedrock-python.github.io/clientwright/agents/)** instead of the
|
|
101
|
+
> whole site: the whole public API with its real defaults, where the boundary between the
|
|
102
|
+
> core engine and an adapter runs, the rules that break code when they are broken, the
|
|
103
|
+
> mistakes models actually make with this API, and a map of which page to fetch for the
|
|
104
|
+
> rest. Every docs page is also served as raw Markdown at its own URL, and a **Copy page**
|
|
105
|
+
> button at the top of each one hands it straight to a chat window.
|
|
106
|
+
|
|
98
107
|
## Why
|
|
99
108
|
|
|
100
109
|
- **Instrumentation under the public API.** The seam sits in the transport
|
|
@@ -38,6 +38,15 @@ handle = inspect(client) # what actually got applied
|
|
|
38
38
|
print(handle.report.dropped) # config the adapter could not express
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
> [!TIP]
|
|
42
|
+
> **Building this with an AI assistant?** Hand it
|
|
43
|
+
> **[one page](https://bedrock-python.github.io/clientwright/agents/)** instead of the
|
|
44
|
+
> whole site: the whole public API with its real defaults, where the boundary between the
|
|
45
|
+
> core engine and an adapter runs, the rules that break code when they are broken, the
|
|
46
|
+
> mistakes models actually make with this API, and a map of which page to fetch for the
|
|
47
|
+
> rest. Every docs page is also served as raw Markdown at its own URL, and a **Copy page**
|
|
48
|
+
> button at the top of each one hands it straight to a chat window.
|
|
49
|
+
|
|
41
50
|
## Why
|
|
42
51
|
|
|
43
52
|
- **Instrumentation under the public API.** The seam sits in the transport
|
|
@@ -52,6 +52,7 @@ from .core.contracts import (
|
|
|
52
52
|
ClientSettingsProtocol,
|
|
53
53
|
DeadlineSource,
|
|
54
54
|
HeaderProvider,
|
|
55
|
+
MaskerProtocol,
|
|
55
56
|
RetrySettingsProtocol,
|
|
56
57
|
SpanProtocol,
|
|
57
58
|
TracerProtocol,
|
|
@@ -73,6 +74,7 @@ from .core.model import IDEMPOTENT_METHODS, CircuitKey, FailureKind, Outcome, Re
|
|
|
73
74
|
from .core.options import CallOptions, call_options, current_call_options
|
|
74
75
|
from .core.plan import ClientHandle, ClientRuntime, inspect_client
|
|
75
76
|
from .core.registry import register_adapter, registered_adapters, resolve_adapter
|
|
77
|
+
from .core.telemetry.redaction import redact_headers
|
|
76
78
|
|
|
77
79
|
# The builders are typed ``Any`` deliberately. The whole product is "you get the
|
|
78
80
|
# REAL native client", and the core cannot name ``httpx.AsyncClient`` without
|
|
@@ -136,6 +138,7 @@ __all__ = [
|
|
|
136
138
|
"DurationBoundary",
|
|
137
139
|
"FailureKind",
|
|
138
140
|
"HeaderProvider",
|
|
141
|
+
"MaskerProtocol",
|
|
139
142
|
"Maybe",
|
|
140
143
|
"NativeConfigError",
|
|
141
144
|
"NativeOptions",
|
|
@@ -173,6 +176,7 @@ __all__ = [
|
|
|
173
176
|
"inspect",
|
|
174
177
|
"inspect_client",
|
|
175
178
|
"is_set",
|
|
179
|
+
"redact_headers",
|
|
176
180
|
"register_adapter",
|
|
177
181
|
"registered_adapters",
|
|
178
182
|
"resolve_adapter",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1" # x-release-please-version
|
|
@@ -30,7 +30,7 @@ from ..core.engine.aio import AsyncAttemptEngine
|
|
|
30
30
|
from ..core.engine.sync import SyncAttemptEngine
|
|
31
31
|
from ..core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
|
|
32
32
|
from ..core.model import IDEMPOTENT_METHODS, ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
|
|
33
|
-
from ..core.native import validate_native
|
|
33
|
+
from ..core.native import accepted_overrides, validate_native
|
|
34
34
|
from ..core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
|
|
35
35
|
from ..core.policy.timeout import base_timeouts
|
|
36
36
|
from ..core.telemetry.emitter import ClientTelemetry
|
|
@@ -622,7 +622,7 @@ class FamilyAdapter:
|
|
|
622
622
|
config_conflicts={},
|
|
623
623
|
)
|
|
624
624
|
|
|
625
|
-
def _compile(self, config: ClientConfig, *, sync: bool) -> CallPlan:
|
|
625
|
+
def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]], *, sync: bool) -> CallPlan:
|
|
626
626
|
applied = {
|
|
627
627
|
Capability.TIMEOUT_CONNECT,
|
|
628
628
|
Capability.TIMEOUT_READ,
|
|
@@ -657,6 +657,7 @@ class FamilyAdapter:
|
|
|
657
657
|
applied_natively=frozenset(applied),
|
|
658
658
|
emulated=frozenset(emulated),
|
|
659
659
|
dropped=dropped,
|
|
660
|
+
native_overrides=accepted_overrides(native),
|
|
660
661
|
)
|
|
661
662
|
plan.report.enforce(config.on_unsupported)
|
|
662
663
|
return plan
|
|
@@ -699,7 +700,7 @@ class FamilyAdapter:
|
|
|
699
700
|
runtime = deps.runtime or ClientRuntime.for_config(
|
|
700
701
|
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
701
702
|
)
|
|
702
|
-
plan = self._compile(config, sync=False)
|
|
703
|
+
plan = self._compile(config, native, sync=False)
|
|
703
704
|
base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
|
|
704
705
|
engine = AsyncAttemptEngine(
|
|
705
706
|
plan=plan,
|
|
@@ -756,7 +757,7 @@ class FamilyAdapter:
|
|
|
756
757
|
runtime = deps.runtime or ClientRuntime.for_config(
|
|
757
758
|
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
758
759
|
)
|
|
759
|
-
plan = self._compile(config, sync=True)
|
|
760
|
+
plan = self._compile(config, native, sync=True)
|
|
760
761
|
base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
|
|
761
762
|
engine = SyncAttemptEngine(
|
|
762
763
|
plan=plan,
|
|
@@ -24,7 +24,7 @@ from ...core.contracts.adapter import AdapterDeps
|
|
|
24
24
|
from ...core.engine.aio import AsyncAttemptEngine
|
|
25
25
|
from ...core.errors import UnsupportedCapabilityError
|
|
26
26
|
from ...core.model import ResolvedTimeouts
|
|
27
|
-
from ...core.native import validate_native
|
|
27
|
+
from ...core.native import accepted_overrides, validate_native
|
|
28
28
|
from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
|
|
29
29
|
from ...core.policy.timeout import base_timeouts
|
|
30
30
|
from ...core.telemetry.emitter import ClientTelemetry
|
|
@@ -116,7 +116,7 @@ class AiohttpAdapter:
|
|
|
116
116
|
config_conflicts={},
|
|
117
117
|
)
|
|
118
118
|
|
|
119
|
-
def _compile(self, config: ClientConfig) -> CallPlan:
|
|
119
|
+
def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan:
|
|
120
120
|
applied = {
|
|
121
121
|
Capability.TIMEOUT_CONNECT,
|
|
122
122
|
Capability.TIMEOUT_READ,
|
|
@@ -146,6 +146,7 @@ class AiohttpAdapter:
|
|
|
146
146
|
applied_natively=frozenset(applied),
|
|
147
147
|
emulated=frozenset(emulated),
|
|
148
148
|
dropped=dropped,
|
|
149
|
+
native_overrides=accepted_overrides(native),
|
|
149
150
|
)
|
|
150
151
|
plan.report.enforce(config.on_unsupported)
|
|
151
152
|
return plan
|
|
@@ -179,7 +180,7 @@ class AiohttpAdapter:
|
|
|
179
180
|
runtime = deps.runtime or ClientRuntime.for_config(
|
|
180
181
|
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
181
182
|
)
|
|
182
|
-
plan = self._compile(config)
|
|
183
|
+
plan = self._compile(config, native)
|
|
183
184
|
base = base_timeouts(config.timeout, _NATIVE_TIMEOUT_DEFAULTS)
|
|
184
185
|
engine = AsyncAttemptEngine(
|
|
185
186
|
plan=plan,
|
|
@@ -25,7 +25,7 @@ from ...core.engine.suppress import is_suppressed, suppressed
|
|
|
25
25
|
from ...core.engine.sync import SyncAttemptEngine
|
|
26
26
|
from ...core.errors import UnsupportedCapabilityError
|
|
27
27
|
from ...core.model import ResolvedTimeouts
|
|
28
|
-
from ...core.native import validate_native
|
|
28
|
+
from ...core.native import accepted_overrides, validate_native
|
|
29
29
|
from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
|
|
30
30
|
from ...core.telemetry.emitter import ClientTelemetry
|
|
31
31
|
from ._imports import HTTPAdapter, requests, urllib3
|
|
@@ -111,7 +111,7 @@ class RequestsAdapter:
|
|
|
111
111
|
config_conflicts={},
|
|
112
112
|
)
|
|
113
113
|
|
|
114
|
-
def _compile(self, config: ClientConfig) -> CallPlan:
|
|
114
|
+
def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan:
|
|
115
115
|
applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE}
|
|
116
116
|
if resolve(config.pool.max_connections_per_host, None) is not None:
|
|
117
117
|
applied.add(Capability.POOL_LIMIT_PER_HOST)
|
|
@@ -136,6 +136,7 @@ class RequestsAdapter:
|
|
|
136
136
|
applied_natively=frozenset(applied),
|
|
137
137
|
emulated=frozenset(emulated),
|
|
138
138
|
dropped=dropped,
|
|
139
|
+
native_overrides=accepted_overrides(native),
|
|
139
140
|
)
|
|
140
141
|
plan.report.enforce(config.on_unsupported)
|
|
141
142
|
return plan
|
|
@@ -155,7 +156,7 @@ class RequestsAdapter:
|
|
|
155
156
|
runtime = deps.runtime or ClientRuntime.for_config(
|
|
156
157
|
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
157
158
|
)
|
|
158
|
-
plan = self._compile(config)
|
|
159
|
+
plan = self._compile(config, native)
|
|
159
160
|
engine = SyncAttemptEngine(
|
|
160
161
|
plan=plan,
|
|
161
162
|
runtime=runtime,
|
|
@@ -24,7 +24,7 @@ from ...core.engine.suppress import is_suppressed, suppressed
|
|
|
24
24
|
from ...core.engine.sync import SyncAttemptEngine
|
|
25
25
|
from ...core.errors import UnsupportedCapabilityError
|
|
26
26
|
from ...core.model import ResolvedTimeouts
|
|
27
|
-
from ...core.native import validate_native
|
|
27
|
+
from ...core.native import accepted_overrides, validate_native
|
|
28
28
|
from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
|
|
29
29
|
from ...core.policy.timeout import base_timeouts
|
|
30
30
|
from ...core.telemetry.emitter import ClientTelemetry
|
|
@@ -141,7 +141,7 @@ class Urllib3Adapter:
|
|
|
141
141
|
config_conflicts={},
|
|
142
142
|
)
|
|
143
143
|
|
|
144
|
-
def _compile(self, config: ClientConfig) -> CallPlan:
|
|
144
|
+
def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan:
|
|
145
145
|
per_host = resolve(config.pool.max_connections_per_host, None)
|
|
146
146
|
applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE}
|
|
147
147
|
if per_host is not None:
|
|
@@ -172,6 +172,7 @@ class Urllib3Adapter:
|
|
|
172
172
|
applied_natively=frozenset(applied),
|
|
173
173
|
emulated=frozenset(emulated),
|
|
174
174
|
dropped=dropped,
|
|
175
|
+
native_overrides=accepted_overrides(native),
|
|
175
176
|
)
|
|
176
177
|
plan.report.enforce(config.on_unsupported)
|
|
177
178
|
return plan
|
|
@@ -215,7 +216,7 @@ class Urllib3Adapter:
|
|
|
215
216
|
runtime = deps.runtime or ClientRuntime.for_config(
|
|
216
217
|
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
217
218
|
)
|
|
218
|
-
plan = self._compile(config)
|
|
219
|
+
plan = self._compile(config, native)
|
|
219
220
|
engine = SyncAttemptEngine(
|
|
220
221
|
plan=plan,
|
|
221
222
|
runtime=runtime,
|
|
@@ -15,10 +15,13 @@ import logging
|
|
|
15
15
|
from collections.abc import Mapping
|
|
16
16
|
from dataclasses import dataclass, field
|
|
17
17
|
from enum import StrEnum
|
|
18
|
-
from typing import Final
|
|
18
|
+
from typing import TYPE_CHECKING, Final
|
|
19
19
|
|
|
20
20
|
from .model import IDEMPOTENT_METHODS, CircuitKey, FailureKind
|
|
21
21
|
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .contracts.observability import MaskerProtocol
|
|
24
|
+
|
|
22
25
|
|
|
23
26
|
class _Unset:
|
|
24
27
|
"""Singleton sentinel distinguishing "user set this" from "default applies"."""
|
|
@@ -106,6 +109,9 @@ DEFAULT_TRIP_KINDS: Final = frozenset(
|
|
|
106
109
|
}
|
|
107
110
|
)
|
|
108
111
|
|
|
112
|
+
# Not a config knob: clientwright never emits headers into logs or spans, so
|
|
113
|
+
# there is nothing for a header list to protect. Kept as a public default for
|
|
114
|
+
# services that log headers themselves via ``redact_headers``.
|
|
109
115
|
DEFAULT_SENSITIVE_HEADERS: Final = frozenset(
|
|
110
116
|
{
|
|
111
117
|
"authorization",
|
|
@@ -261,14 +267,26 @@ _INFO_LEVEL: Final = logging.INFO
|
|
|
261
267
|
|
|
262
268
|
@dataclass(frozen=True, slots=True)
|
|
263
269
|
class ObservabilityConfig:
|
|
264
|
-
"""Which telemetry channels are active; only knobs that actually work exist here.
|
|
270
|
+
"""Which telemetry channels are active; only knobs that actually work exist here.
|
|
271
|
+
|
|
272
|
+
Two-stage URL scrubbing before anything reaches a log line or a span:
|
|
273
|
+
``sensitive_query_params`` redacts by parameter *name*, then ``url_masker``
|
|
274
|
+
(if set) sees the whole redacted URL and may scrub PII by *value* - the
|
|
275
|
+
email in a path segment that no name list can catch. See
|
|
276
|
+
:class:`~clientwright.core.contracts.observability.MaskerProtocol` for the
|
|
277
|
+
failure contract.
|
|
278
|
+
"""
|
|
265
279
|
|
|
266
280
|
logging: bool = True
|
|
267
281
|
metrics: bool = True
|
|
268
282
|
tracing: bool = True
|
|
269
283
|
success_log_level: int = _INFO_LEVEL
|
|
270
|
-
sensitive_headers: frozenset[str] = DEFAULT_SENSITIVE_HEADERS
|
|
271
284
|
sensitive_query_params: frozenset[str] = DEFAULT_SENSITIVE_QUERY_PARAMS
|
|
285
|
+
url_masker: MaskerProtocol | None = None
|
|
286
|
+
|
|
287
|
+
def __post_init__(self) -> None:
|
|
288
|
+
if self.url_masker is not None and not callable(self.url_masker):
|
|
289
|
+
raise ValueError(f"observability.url_masker must be callable or None, got {self.url_masker!r}")
|
|
272
290
|
|
|
273
291
|
|
|
274
292
|
@dataclass(frozen=True, slots=True)
|
|
@@ -9,6 +9,7 @@ from .message import RequestView as RequestView
|
|
|
9
9
|
from .message import ResponseView as ResponseView
|
|
10
10
|
from .message import SyncNormalizer as SyncNormalizer
|
|
11
11
|
from .observability import ClientMetricsProtocol as ClientMetricsProtocol
|
|
12
|
+
from .observability import MaskerProtocol as MaskerProtocol
|
|
12
13
|
from .observability import SpanProtocol as SpanProtocol
|
|
13
14
|
from .observability import TracerProtocol as TracerProtocol
|
|
14
15
|
from .settings import CircuitBreakerSettingsProtocol as CircuitBreakerSettingsProtocol
|
|
@@ -24,6 +25,7 @@ __all__ = [
|
|
|
24
25
|
"ClientSettingsProtocol",
|
|
25
26
|
"DeadlineSource",
|
|
26
27
|
"HeaderProvider",
|
|
28
|
+
"MaskerProtocol",
|
|
27
29
|
"RequestView",
|
|
28
30
|
"ResponseView",
|
|
29
31
|
"RetrySettingsProtocol",
|
|
@@ -69,6 +69,24 @@ class ClientMetricsProtocol(Protocol):
|
|
|
69
69
|
def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None: ...
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class MaskerProtocol(Protocol):
|
|
74
|
+
"""Value-level masker applied to telemetry strings before they are emitted.
|
|
75
|
+
|
|
76
|
+
The key-based redaction (``sensitive_query_params``) runs first; the masker
|
|
77
|
+
then sees the whole string and may scrub PII that key lists cannot reach -
|
|
78
|
+
an email in a path segment, a card number in a free-form value. Any
|
|
79
|
+
``(str) -> str`` callable qualifies: a regex substitution, a
|
|
80
|
+
format-preserving masker, a locally hosted NER model.
|
|
81
|
+
|
|
82
|
+
Contract: synchronous, and cheap enough to run 2-3 times per HTTP call.
|
|
83
|
+
A raising masker never breaks the request - the emitter replaces the whole
|
|
84
|
+
value with ``[redacted]`` (fail closed) and warns once per client.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __call__(self, value: str) -> str: ...
|
|
88
|
+
|
|
89
|
+
|
|
72
90
|
@runtime_checkable
|
|
73
91
|
class SpanProtocol(Protocol):
|
|
74
92
|
"""One client span; ended exactly once, in a finally block."""
|
|
@@ -89,4 +107,4 @@ class TracerProtocol(Protocol):
|
|
|
89
107
|
def inject_context(self, headers: MutableMapping[str, str]) -> None: ...
|
|
90
108
|
|
|
91
109
|
|
|
92
|
-
__all__ = ["ClientMetricsProtocol", "SpanProtocol", "TracerProtocol"]
|
|
110
|
+
__all__ = ["ClientMetricsProtocol", "MaskerProtocol", "SpanProtocol", "TracerProtocol"]
|
|
@@ -214,10 +214,11 @@ class AsyncAttemptEngine:
|
|
|
214
214
|
duration=runtime.clock() - attempt_started,
|
|
215
215
|
outcome=outcome,
|
|
216
216
|
hop=observation.hops,
|
|
217
|
+
conn=self._norm.conn_metrics(response) if response is not None else None,
|
|
217
218
|
)
|
|
218
219
|
history.append(attempt)
|
|
219
220
|
if plan.emit_attempt_metrics:
|
|
220
|
-
self._telemetry.attempt_end(info, attempt)
|
|
221
|
+
self._telemetry.attempt_end(observation, info, attempt)
|
|
221
222
|
if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired:
|
|
222
223
|
raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception
|
|
223
224
|
if plan.retry_policy is None:
|
|
@@ -209,10 +209,11 @@ class SyncAttemptEngine:
|
|
|
209
209
|
duration=runtime.clock() - attempt_started,
|
|
210
210
|
outcome=outcome,
|
|
211
211
|
hop=observation.hops,
|
|
212
|
+
conn=self._norm.conn_metrics(response) if response is not None else None,
|
|
212
213
|
)
|
|
213
214
|
history.append(attempt)
|
|
214
215
|
if plan.emit_attempt_metrics:
|
|
215
|
-
self._telemetry.attempt_end(info, attempt)
|
|
216
|
+
self._telemetry.attempt_end(observation, info, attempt)
|
|
216
217
|
if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired:
|
|
217
218
|
raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception
|
|
218
219
|
if plan.retry_policy is None:
|
|
@@ -93,7 +93,15 @@ class TooManyRedirectsError(CallError):
|
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
class NotReplayableError(CallError):
|
|
96
|
-
"""
|
|
96
|
+
"""A body that cannot be replayed made a required repeat impossible.
|
|
97
|
+
|
|
98
|
+
The engine never raises this. A non-replayable body ends the call with the
|
|
99
|
+
response it already has plus a ``retry_skipped{reason="non_replayable"}``
|
|
100
|
+
counter, so ``except NotReplayableError`` around a call never fires. It is
|
|
101
|
+
part of the public ``CallError`` family for adapters and callers that choose
|
|
102
|
+
to make that refusal fatal themselves; the adapter translators pass it
|
|
103
|
+
through unchanged rather than dressing it in an SDK error class.
|
|
104
|
+
"""
|
|
97
105
|
|
|
98
106
|
|
|
99
107
|
__all__ = [
|
|
@@ -81,4 +81,9 @@ def validate_native(
|
|
|
81
81
|
return validated
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
def accepted_overrides(validated: Mapping[str, Mapping[str, object]]) -> dict[str, tuple[str, ...]]:
|
|
85
|
+
"""Report shape of validated passthrough: slot -> the keys that survived validation."""
|
|
86
|
+
return {slot: tuple(sorted(values)) for slot, values in validated.items() if values}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["accepted_overrides", "validate_native"]
|
|
@@ -11,7 +11,7 @@ from dataclasses import dataclass
|
|
|
11
11
|
from random import Random
|
|
12
12
|
|
|
13
13
|
from ..config import RetryConfig
|
|
14
|
-
from ..model import Attempt, FailureKind, RequestInfo
|
|
14
|
+
from ..model import IDEMPOTENT_METHODS, Attempt, FailureKind, RequestInfo
|
|
15
15
|
|
|
16
16
|
# A retry whose backoff would land this close to the deadline is pointless.
|
|
17
17
|
_DEADLINE_SLACK = 0.001
|
|
@@ -40,6 +40,20 @@ class DefaultRetryPolicy:
|
|
|
40
40
|
return f"kind_{kind.value}"
|
|
41
41
|
return None
|
|
42
42
|
|
|
43
|
+
def _method_allows_retry(self, info: RequestInfo) -> bool:
|
|
44
|
+
"""Two vetoes on repeating this method, either of which is enough.
|
|
45
|
+
|
|
46
|
+
``RequestInfo.idempotent`` restates the method's RFC default unless the
|
|
47
|
+
CALL SITE overrode it, so a flag disagreeing with ``IDEMPOTENT_METHODS``
|
|
48
|
+
is the call site talking and decides on its own - that is what makes
|
|
49
|
+
``idempotent=True`` unlock a POST and ``idempotent=False`` veto a GET.
|
|
50
|
+
A flag that only restates the default leaves the decision with the
|
|
51
|
+
operator's ``retry.methods``.
|
|
52
|
+
"""
|
|
53
|
+
if info.idempotent != (info.method in IDEMPOTENT_METHODS):
|
|
54
|
+
return info.idempotent
|
|
55
|
+
return info.method in self._config.methods
|
|
56
|
+
|
|
43
57
|
def _backoff(self, attempt_index: int, retry_after: float | None, rng: Random) -> float:
|
|
44
58
|
config = self._config
|
|
45
59
|
if config.respect_retry_after and retry_after is not None:
|
|
@@ -67,7 +81,7 @@ class DefaultRetryPolicy:
|
|
|
67
81
|
return RetryDecision(retry=False, reason="final")
|
|
68
82
|
if len(history) >= config.max_attempts:
|
|
69
83
|
return RetryDecision(retry=False, reason="attempts")
|
|
70
|
-
if
|
|
84
|
+
if not self._method_allows_retry(info):
|
|
71
85
|
return RetryDecision(retry=False, reason="method")
|
|
72
86
|
if config.require_replayable_body and not replayable:
|
|
73
87
|
return RetryDecision(retry=False, reason="non_replayable")
|
|
@@ -12,10 +12,10 @@ from dataclasses import dataclass
|
|
|
12
12
|
|
|
13
13
|
from ..config import ObservabilityConfig
|
|
14
14
|
from ..contracts.observability import ClientMetricsProtocol, SpanProtocol, TracerProtocol
|
|
15
|
-
from ..model import Attempt, Outcome, RequestInfo
|
|
15
|
+
from ..model import Attempt, ConnMetrics, Outcome, RequestInfo
|
|
16
16
|
from .names import OUTCOME_SUCCESS, ROUTE_UNKNOWN, STATUS_NONE
|
|
17
17
|
from .null import NullMetrics, NullTracer
|
|
18
|
-
from .redaction import redact_url
|
|
18
|
+
from .redaction import REDACTED, redact_url
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def outcome_label(outcome: Outcome) -> str:
|
|
@@ -57,13 +57,26 @@ class ClientTelemetry:
|
|
|
57
57
|
self._tracer: TracerProtocol = tracer if (tracer and config.tracing) else NullTracer()
|
|
58
58
|
self._logger = logging.getLogger(f"clientwright.client.{service}")
|
|
59
59
|
self._log_enabled = config.logging
|
|
60
|
+
self._masker_warned = False
|
|
60
61
|
|
|
61
62
|
@property
|
|
62
63
|
def tracer(self) -> TracerProtocol:
|
|
63
64
|
return self._tracer
|
|
64
65
|
|
|
65
66
|
def _safe_url(self, info: RequestInfo) -> str:
|
|
66
|
-
|
|
67
|
+
url = redact_url(info.url, self._config.sensitive_query_params)
|
|
68
|
+
masker = self._config.url_masker
|
|
69
|
+
if masker is None:
|
|
70
|
+
return url
|
|
71
|
+
# Fail closed: a broken masker must not publish the raw URL, and must
|
|
72
|
+
# not break the request either. Warn once per client, not per call.
|
|
73
|
+
try:
|
|
74
|
+
return masker(url)
|
|
75
|
+
except Exception:
|
|
76
|
+
if not self._masker_warned:
|
|
77
|
+
self._masker_warned = True
|
|
78
|
+
self._logger.warning("url_masker raised; emitting [redacted] instead of the URL", exc_info=True)
|
|
79
|
+
return REDACTED
|
|
67
80
|
|
|
68
81
|
def call_start(self, info: RequestInfo, started: float) -> CallObservation:
|
|
69
82
|
span = self._tracer.start_span(
|
|
@@ -84,7 +97,7 @@ class ClientTelemetry:
|
|
|
84
97
|
)
|
|
85
98
|
return CallObservation(span=span, started=started)
|
|
86
99
|
|
|
87
|
-
def attempt_end(self, info: RequestInfo, attempt: Attempt) -> None:
|
|
100
|
+
def attempt_end(self, observation: CallObservation, info: RequestInfo, attempt: Attempt) -> None:
|
|
88
101
|
self._metrics.record_attempt(
|
|
89
102
|
service=self._service,
|
|
90
103
|
adapter=self._adapter,
|
|
@@ -94,6 +107,25 @@ class ClientTelemetry:
|
|
|
94
107
|
outcome=outcome_label(attempt.outcome),
|
|
95
108
|
duration=attempt.duration,
|
|
96
109
|
)
|
|
110
|
+
if attempt.conn is not None:
|
|
111
|
+
self._record_conn(observation.span, attempt.conn)
|
|
112
|
+
|
|
113
|
+
def _record_conn(self, span: SpanProtocol, conn: ConnMetrics) -> None:
|
|
114
|
+
"""Connection timings onto the call span; the last attempt that saw them wins.
|
|
115
|
+
|
|
116
|
+
The metric families are a frozen contract with no room for them, so the
|
|
117
|
+
span is where an adapter that can observe them surfaces them.
|
|
118
|
+
"""
|
|
119
|
+
for key, value in (
|
|
120
|
+
("http.connection.dns_duration", conn.dns),
|
|
121
|
+
("http.connection.connect_duration", conn.connect),
|
|
122
|
+
("http.connection.tls_duration", conn.tls),
|
|
123
|
+
("http.connection.pool_wait_duration", conn.pool_wait),
|
|
124
|
+
("http.connection.reused", conn.reused),
|
|
125
|
+
("network.protocol.version", conn.http_version),
|
|
126
|
+
):
|
|
127
|
+
if value is not None:
|
|
128
|
+
span.set_attribute(key, value)
|
|
97
129
|
|
|
98
130
|
def redirect_hop(self, observation: CallObservation) -> None:
|
|
99
131
|
observation.hops += 1
|
|
@@ -22,10 +22,11 @@ from clientwright.core.capabilities import (
|
|
|
22
22
|
)
|
|
23
23
|
from clientwright.core.config import ClientConfig, RetryConfig
|
|
24
24
|
from clientwright.core.contracts.adapter import AdapterDeps
|
|
25
|
+
from clientwright.core.contracts.observability import TracerProtocol
|
|
25
26
|
from clientwright.core.engine.aio import AsyncAttemptEngine
|
|
26
27
|
from clientwright.core.engine.base import default_response_outcome
|
|
27
28
|
from clientwright.core.engine.sync import SyncAttemptEngine
|
|
28
|
-
from clientwright.core.model import FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
|
|
29
|
+
from clientwright.core.model import ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
|
|
29
30
|
from clientwright.core.plan import ClientRuntime, compile_plan
|
|
30
31
|
from clientwright.core.telemetry.emitter import ClientTelemetry
|
|
31
32
|
from clientwright.core.testing import RecordingMetrics
|
|
@@ -182,12 +183,13 @@ def as_async_send(script: ScriptedSend) -> Callable[[EngineRequest], Any]:
|
|
|
182
183
|
class FakeNormalizer:
|
|
183
184
|
"""Both normalizer flavors in one object; the request/response ARE the views."""
|
|
184
185
|
|
|
185
|
-
def __init__(self, *, freeze_ok: bool = True) -> None:
|
|
186
|
+
def __init__(self, *, freeze_ok: bool = True, conn: ConnMetrics | None = None) -> None:
|
|
186
187
|
self.freeze_ok = freeze_ok
|
|
187
188
|
self.freezes = 0
|
|
188
189
|
self.rewinds = 0
|
|
189
190
|
self.discards = 0
|
|
190
191
|
self.wrapped_streams = 0
|
|
192
|
+
self.conn = conn
|
|
191
193
|
|
|
192
194
|
def wrap_request(self, native: Any) -> EngineRequest:
|
|
193
195
|
assert isinstance(native, EngineRequest)
|
|
@@ -212,8 +214,8 @@ class FakeNormalizer:
|
|
|
212
214
|
def wrap_stream(self, response: FakeResponse, on_done: Callable[[Outcome, float], None]) -> None:
|
|
213
215
|
self.wrapped_streams += 1
|
|
214
216
|
|
|
215
|
-
def conn_metrics(self, response: FakeResponse) -> None:
|
|
216
|
-
return
|
|
217
|
+
def conn_metrics(self, response: FakeResponse) -> ConnMetrics | None:
|
|
218
|
+
return self.conn
|
|
217
219
|
|
|
218
220
|
# -- sync flavor -------------------------------------------------------
|
|
219
221
|
|
|
@@ -261,6 +263,8 @@ class Harness:
|
|
|
261
263
|
freeze_ok: bool = True,
|
|
262
264
|
sync: bool = False,
|
|
263
265
|
clock: Callable[[], float] | None = None,
|
|
266
|
+
tracer: TracerProtocol | None = None,
|
|
267
|
+
conn: ConnMetrics | None = None,
|
|
264
268
|
) -> None:
|
|
265
269
|
self.config = config
|
|
266
270
|
self.metrics = RecordingMetrics()
|
|
@@ -273,11 +277,11 @@ class Harness:
|
|
|
273
277
|
seam="test",
|
|
274
278
|
config=config.observability,
|
|
275
279
|
metrics=self.metrics,
|
|
276
|
-
tracer=
|
|
280
|
+
tracer=tracer,
|
|
277
281
|
)
|
|
278
282
|
self.normalizer: FakeSyncNormalizer | FakeAsyncNormalizer
|
|
279
283
|
if sync:
|
|
280
|
-
self.normalizer = FakeSyncNormalizer(freeze_ok=freeze_ok)
|
|
284
|
+
self.normalizer = FakeSyncNormalizer(freeze_ok=freeze_ok, conn=conn)
|
|
281
285
|
self.engine: Any = SyncAttemptEngine(
|
|
282
286
|
plan=self.plan,
|
|
283
287
|
runtime=self.runtime,
|
|
@@ -287,7 +291,7 @@ class Harness:
|
|
|
287
291
|
translate=lambda error: error,
|
|
288
292
|
)
|
|
289
293
|
else:
|
|
290
|
-
self.normalizer = FakeAsyncNormalizer(freeze_ok=freeze_ok)
|
|
294
|
+
self.normalizer = FakeAsyncNormalizer(freeze_ok=freeze_ok, conn=conn)
|
|
291
295
|
self.engine = AsyncAttemptEngine(
|
|
292
296
|
plan=self.plan,
|
|
293
297
|
runtime=self.runtime,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Telemetry doubles: a tracer that keeps every span it opened."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, MutableMapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class RecordingSpan:
|
|
11
|
+
"""SpanProtocol double keeping every attribute the emitter sets."""
|
|
12
|
+
|
|
13
|
+
attributes: dict[str, object] = field(default_factory=dict)
|
|
14
|
+
ended: bool = False
|
|
15
|
+
|
|
16
|
+
def set_attribute(self, key: str, value: str | int | float | bool) -> None:
|
|
17
|
+
self.attributes[key] = value
|
|
18
|
+
|
|
19
|
+
def record_failure(self, description: str) -> None:
|
|
20
|
+
self.attributes["failure"] = description
|
|
21
|
+
|
|
22
|
+
def end(self) -> None:
|
|
23
|
+
self.ended = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class RecordingTracer:
|
|
28
|
+
"""TracerProtocol double; one RecordingSpan per logical call."""
|
|
29
|
+
|
|
30
|
+
spans: list[RecordingSpan] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> RecordingSpan:
|
|
33
|
+
span = RecordingSpan(attributes=dict(attributes))
|
|
34
|
+
self.spans.append(span)
|
|
35
|
+
return span
|
|
36
|
+
|
|
37
|
+
def inject_context(self, headers: MutableMapping[str, str]) -> None:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["RecordingSpan", "RecordingTracer"]
|