clientwright 0.2.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.0 → clientwright-0.2.1}/CHANGELOG.md +7 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/PKG-INFO +10 -1
- {clientwright-0.2.0 → clientwright-0.2.1}/README.md +9 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/__init__.py +2 -0
- clientwright-0.2.1/clientwright/__version__.py +1 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/_httpx_shared.py +5 -4
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/adapter.py +4 -3
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/adapter.py +4 -3
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/adapter.py +4 -3
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/aio.py +2 -1
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/sync.py +2 -1
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/errors.py +9 -1
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/native.py +6 -1
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/retry.py +16 -2
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/emitter.py +21 -2
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/engine.py +11 -7
- clientwright-0.2.1/tests/helpers/telemetry.py +41 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_aiohttp.py +19 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/parity/test_semantics.py +33 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_build.py +15 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/test_units.py +1 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_adapter.py +1 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_adapter.py +11 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_aio.py +27 -1
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_sync.py +13 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry.py +31 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_emitter.py +40 -2
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction.py +7 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities.py +9 -1
- clientwright-0.2.0/clientwright/__version__.py +0 -1
- {clientwright-0.2.0 → clientwright-0.2.1}/.gitignore +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/LICENSE +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/_lazy.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/_imports.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/capabilities.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/classify.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/errors.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/middleware.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/options.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/trace.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/views.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/_imports.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/adapter.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/capabilities.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/classify.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/errors.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize_sync.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/transport.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/views.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/_imports.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/adapter.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/capabilities.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/classify.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/errors.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize_sync.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/transport.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/views.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/prometheus.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/otel.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/_imports.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/capabilities.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/classify.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/errors.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/views.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/_imports.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/capabilities.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/classify.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/errors.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/views.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/contrib/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/contrib/deadline.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/contrib/dishka.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/balancer/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/balancer/policy.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/capabilities.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/config.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/adapter.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/context.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/message.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/observability.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/settings.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/base.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/redirects.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/suppress.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/model.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/options.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/plan.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/budget.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/circuit.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/concurrency.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/timeout.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/registry.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/names.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/null.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/redaction.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/testing/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/testing/doubles.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/testing/origin.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/py.typed +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/pyproject.toml +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/conftest.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/drivers.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/views.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx2.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_async.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_sync.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_requests.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_urllib3.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/chaos/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/chaos/test_faults.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/chaos/test_origin_faults.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/conftest.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/contrib/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/contrib/test_deadline.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/contrib/test_dishka.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/parity/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/conftest.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/test_public_endpoints.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/test_tls_and_dns.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_trace.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_units.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/conftest.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/test_units.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_exports.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_otel.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_prometheus.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_units.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/test_bare_install.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_family_parity.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_shared.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_normalize.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_units.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline_context.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/test_dishka.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_base.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_redirects.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_suppress.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_budget.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit_properties.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_concurrency.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry_properties.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout_properties.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/__init__.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_masking.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction_properties.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_balancer.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities_properties.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_config.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_enum_coercion.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_errors.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_model.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_options.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_plan.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_registry.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_settings.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/test_docs_snippets.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/test_sdk_imports_are_guarded.py +0 -0
- {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/test_version.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
10
|
## [0.2.0](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.1.0...clientwright-v0.2.0) (2026-08-14)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: clientwright
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -74,6 +74,7 @@ from .core.model import IDEMPOTENT_METHODS, CircuitKey, FailureKind, Outcome, Re
|
|
|
74
74
|
from .core.options import CallOptions, call_options, current_call_options
|
|
75
75
|
from .core.plan import ClientHandle, ClientRuntime, inspect_client
|
|
76
76
|
from .core.registry import register_adapter, registered_adapters, resolve_adapter
|
|
77
|
+
from .core.telemetry.redaction import redact_headers
|
|
77
78
|
|
|
78
79
|
# The builders are typed ``Any`` deliberately. The whole product is "you get the
|
|
79
80
|
# REAL native client", and the core cannot name ``httpx.AsyncClient`` without
|
|
@@ -175,6 +176,7 @@ __all__ = [
|
|
|
175
176
|
"inspect",
|
|
176
177
|
"inspect_client",
|
|
177
178
|
"is_set",
|
|
179
|
+
"redact_headers",
|
|
178
180
|
"register_adapter",
|
|
179
181
|
"registered_adapters",
|
|
180
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,
|
|
@@ -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,7 +12,7 @@ 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
18
|
from .redaction import REDACTED, redact_url
|
|
@@ -97,7 +97,7 @@ class ClientTelemetry:
|
|
|
97
97
|
)
|
|
98
98
|
return CallObservation(span=span, started=started)
|
|
99
99
|
|
|
100
|
-
def attempt_end(self, info: RequestInfo, attempt: Attempt) -> None:
|
|
100
|
+
def attempt_end(self, observation: CallObservation, info: RequestInfo, attempt: Attempt) -> None:
|
|
101
101
|
self._metrics.record_attempt(
|
|
102
102
|
service=self._service,
|
|
103
103
|
adapter=self._adapter,
|
|
@@ -107,6 +107,25 @@ class ClientTelemetry:
|
|
|
107
107
|
outcome=outcome_label(attempt.outcome),
|
|
108
108
|
duration=attempt.duration,
|
|
109
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)
|
|
110
129
|
|
|
111
130
|
def redirect_hop(self, observation: CallObservation) -> None:
|
|
112
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"]
|
|
@@ -21,6 +21,7 @@ from clientwright.adapters.aiohttp import ( # noqa: E402
|
|
|
21
21
|
)
|
|
22
22
|
from clientwright.core.config import CircuitBreakerConfig # noqa: E402
|
|
23
23
|
from clientwright.core.testing import OriginServer, RecordingMetrics # noqa: E402
|
|
24
|
+
from tests.helpers.telemetry import RecordingTracer # noqa: E402
|
|
24
25
|
|
|
25
26
|
from ..conftest import base_config # noqa: E402
|
|
26
27
|
|
|
@@ -239,3 +240,21 @@ async def test__failure_paths__never_leak_inflight(
|
|
|
239
240
|
statuses = [record["status"] for record in metrics.calls]
|
|
240
241
|
assert statuses.count("none") == 2 # both failures observed with status=none
|
|
241
242
|
await client.close()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def test__conn_metrics__annotate_the_call_span(origin: OriginServer) -> None:
|
|
246
|
+
tracer = RecordingTracer()
|
|
247
|
+
client = await build(base_config(origin), AdapterDeps(tracer=tracer))
|
|
248
|
+
# Read each body before the next call: aiohttp returns a connection to the
|
|
249
|
+
# pool on release, so an unread response leaves the second call racing the
|
|
250
|
+
# first one's cleanup for it -- which is how this test used to flake.
|
|
251
|
+
first = await client.get("/echo")
|
|
252
|
+
await first.read()
|
|
253
|
+
second = await client.get("/echo")
|
|
254
|
+
await second.read()
|
|
255
|
+
await client.close()
|
|
256
|
+
fresh, pooled = (span.attributes for span in tracer.spans)
|
|
257
|
+
assert fresh["http.connection.connect_duration"] >= 0.0 # TraceConfig timed the handshake
|
|
258
|
+
assert fresh["http.connection.reused"] is False
|
|
259
|
+
assert pooled["http.connection.reused"] is True # second call took the pooled connection
|
|
260
|
+
assert "http.connection.connect_duration" not in pooled
|
|
@@ -144,6 +144,39 @@ def test__post_idempotency_gate__same_on_every_adapter_sync(adapter_name: str, o
|
|
|
144
144
|
driver.close(client)
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
# --- GET: the call-site veto stops a retry the method would have allowed -----
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.parametrize("adapter_name", adapter_params(ASYNC_ADAPTERS))
|
|
151
|
+
async def test__get_idempotency_veto__same_on_every_adapter(adapter_name: str, origin: OriginServer) -> None:
|
|
152
|
+
driver = get_driver(adapter_name)
|
|
153
|
+
metrics, deps = fresh_deps()
|
|
154
|
+
client = driver.build(battery_config(driver, origin, retry=FAST_RETRY), deps)
|
|
155
|
+
path = flaky_path(adapter_name, 1)
|
|
156
|
+
try:
|
|
157
|
+
response = await driver.request(client, origin.url, "GET", path, idempotent=False)
|
|
158
|
+
finally:
|
|
159
|
+
await driver.close(client)
|
|
160
|
+
assert response.status == 503 # the call site said this GET must not be repeated
|
|
161
|
+
assert origin.request_count(path) == 1
|
|
162
|
+
assert [record["reason"] for record in metrics.retry_skips] == ["method"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize("adapter_name", adapter_params(SYNC_ADAPTERS))
|
|
166
|
+
def test__get_idempotency_veto__same_on_every_adapter_sync(adapter_name: str, origin: OriginServer) -> None:
|
|
167
|
+
driver = get_driver(adapter_name)
|
|
168
|
+
metrics, deps = fresh_deps()
|
|
169
|
+
client = driver.build(battery_config(driver, origin, retry=FAST_RETRY), deps)
|
|
170
|
+
path = flaky_path(adapter_name, 1)
|
|
171
|
+
try:
|
|
172
|
+
response = driver.request(client, origin.url, "GET", path, idempotent=False)
|
|
173
|
+
finally:
|
|
174
|
+
driver.close(client)
|
|
175
|
+
assert response.status == 503
|
|
176
|
+
assert origin.request_count(path) == 1
|
|
177
|
+
assert [record["reason"] for record in metrics.retry_skips] == ["method"]
|
|
178
|
+
|
|
179
|
+
|
|
147
180
|
# --- circuit breaker: one 5xx signal, local rejection, origin untouched ------
|
|
148
181
|
|
|
149
182
|
|
|
@@ -16,6 +16,7 @@ from clientwright.adapters.aiohttp.adapter import _proxy_router, _ssl_argument
|
|
|
16
16
|
from clientwright.core.capabilities import Capability # noqa: E402
|
|
17
17
|
from clientwright.core.config import ( # noqa: E402
|
|
18
18
|
ClientConfig,
|
|
19
|
+
NativeOptions,
|
|
19
20
|
PoolConfig,
|
|
20
21
|
ProxyConfig,
|
|
21
22
|
TimeoutConfig,
|
|
@@ -113,6 +114,20 @@ async def test__per_host_limit__applied_natively_on_the_connector() -> None:
|
|
|
113
114
|
await handle.aclose()
|
|
114
115
|
|
|
115
116
|
|
|
117
|
+
async def test__native_passthrough__applied_and_listed_in_the_report() -> None:
|
|
118
|
+
config = ClientConfig(
|
|
119
|
+
service_name="s",
|
|
120
|
+
native=NativeOptions.of(session={"auto_decompress": False}, connector={"use_dns_cache": False}),
|
|
121
|
+
)
|
|
122
|
+
handle = clientwright.build_handle("aiohttp", config)
|
|
123
|
+
try:
|
|
124
|
+
assert handle.report.native_overrides == {"session": ("auto_decompress",), "connector": ("use_dns_cache",)}
|
|
125
|
+
assert handle.client.connector.use_dns_cache is False
|
|
126
|
+
finally:
|
|
127
|
+
assert handle.aclose is not None
|
|
128
|
+
await handle.aclose()
|
|
129
|
+
|
|
130
|
+
|
|
116
131
|
async def test__proxy__reported_emulated() -> None:
|
|
117
132
|
config = ClientConfig(service_name="s", proxy=ProxyConfig(url="http://proxy.local:3128"))
|
|
118
133
|
handle = clientwright.build_handle("aiohttp", config)
|
|
@@ -203,6 +203,7 @@ def test__legit_native_options__accepted() -> None:
|
|
|
203
203
|
)
|
|
204
204
|
handle = clientwright.build_handle("httpx", config)
|
|
205
205
|
assert type(handle.client) is httpx.AsyncClient
|
|
206
|
+
assert handle.report.native_overrides == {"client": ("trust_env",), "transport": ("local_address",)}
|
|
206
207
|
|
|
207
208
|
|
|
208
209
|
# --- TLS wiring --------------------------------------------------------------
|
|
@@ -147,6 +147,7 @@ def test__native_session_attributes__applied_to_the_built_session() -> None:
|
|
|
147
147
|
handle = clientwright.build_sync_handle("requests", config)
|
|
148
148
|
try:
|
|
149
149
|
assert handle.client.trust_env is False
|
|
150
|
+
assert handle.report.native_overrides == {"session": ("trust_env",)}
|
|
150
151
|
finally:
|
|
151
152
|
assert handle.close is not None
|
|
152
153
|
handle.close()
|
|
@@ -17,6 +17,7 @@ import clientwright # noqa: E402
|
|
|
17
17
|
from clientwright.core.capabilities import Capability # noqa: E402
|
|
18
18
|
from clientwright.core.config import ( # noqa: E402
|
|
19
19
|
ClientConfig,
|
|
20
|
+
NativeOptions,
|
|
20
21
|
PoolConfig,
|
|
21
22
|
ProxyConfig,
|
|
22
23
|
TimeoutConfig,
|
|
@@ -78,6 +79,16 @@ def test__non_blocking_pool__never_passes_a_pool_timeout(monkeypatch: pytest.Mon
|
|
|
78
79
|
# --- capability reporting ----------------------------------------------------
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def test__native_passthrough__listed_in_the_report() -> None:
|
|
83
|
+
config = ClientConfig(service_name="s", native=NativeOptions.of(manager={"strict": True}))
|
|
84
|
+
handle = clientwright.build_sync_handle("urllib3", config)
|
|
85
|
+
try:
|
|
86
|
+
assert handle.report.native_overrides == {"manager": ("strict",)}
|
|
87
|
+
finally:
|
|
88
|
+
assert handle.close is not None
|
|
89
|
+
handle.close()
|
|
90
|
+
|
|
91
|
+
|
|
81
92
|
def test__blocking_pool__pool_knobs_reported_applied_natively() -> None:
|
|
82
93
|
config = ClientConfig(
|
|
83
94
|
service_name="s",
|