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.
Files changed (199) hide show
  1. {clientwright-0.2.0 → clientwright-0.2.1}/CHANGELOG.md +7 -0
  2. {clientwright-0.2.0 → clientwright-0.2.1}/PKG-INFO +10 -1
  3. {clientwright-0.2.0 → clientwright-0.2.1}/README.md +9 -0
  4. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/__init__.py +2 -0
  5. clientwright-0.2.1/clientwright/__version__.py +1 -0
  6. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/_httpx_shared.py +5 -4
  7. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/adapter.py +4 -3
  8. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/adapter.py +4 -3
  9. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/adapter.py +4 -3
  10. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/aio.py +2 -1
  11. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/sync.py +2 -1
  12. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/errors.py +9 -1
  13. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/native.py +6 -1
  14. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/retry.py +16 -2
  15. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/emitter.py +21 -2
  16. {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/engine.py +11 -7
  17. clientwright-0.2.1/tests/helpers/telemetry.py +41 -0
  18. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_aiohttp.py +19 -0
  19. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/parity/test_semantics.py +33 -0
  20. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_build.py +15 -0
  21. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/test_units.py +1 -0
  22. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_adapter.py +1 -0
  23. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_adapter.py +11 -0
  24. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_aio.py +27 -1
  25. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_sync.py +13 -0
  26. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry.py +31 -0
  27. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_emitter.py +40 -2
  28. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction.py +7 -0
  29. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities.py +9 -1
  30. clientwright-0.2.0/clientwright/__version__.py +0 -1
  31. {clientwright-0.2.0 → clientwright-0.2.1}/.gitignore +0 -0
  32. {clientwright-0.2.0 → clientwright-0.2.1}/LICENSE +0 -0
  33. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/__init__.py +0 -0
  34. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/_lazy.py +0 -0
  35. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/__init__.py +0 -0
  36. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/_imports.py +0 -0
  37. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/capabilities.py +0 -0
  38. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/classify.py +0 -0
  39. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/errors.py +0 -0
  40. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/middleware.py +0 -0
  41. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/normalize.py +0 -0
  42. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/options.py +0 -0
  43. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/trace.py +0 -0
  44. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/views.py +0 -0
  45. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/__init__.py +0 -0
  46. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/_imports.py +0 -0
  47. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/adapter.py +0 -0
  48. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/capabilities.py +0 -0
  49. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/classify.py +0 -0
  50. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/errors.py +0 -0
  51. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize.py +0 -0
  52. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize_sync.py +0 -0
  53. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/transport.py +0 -0
  54. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx/views.py +0 -0
  55. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/__init__.py +0 -0
  56. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/_imports.py +0 -0
  57. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/adapter.py +0 -0
  58. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/capabilities.py +0 -0
  59. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/classify.py +0 -0
  60. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/errors.py +0 -0
  61. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize.py +0 -0
  62. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize_sync.py +0 -0
  63. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/transport.py +0 -0
  64. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/views.py +0 -0
  65. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/__init__.py +0 -0
  66. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/__init__.py +0 -0
  67. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/prometheus.py +0 -0
  68. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/__init__.py +0 -0
  69. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/otel.py +0 -0
  70. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/__init__.py +0 -0
  71. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/_imports.py +0 -0
  72. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/capabilities.py +0 -0
  73. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/classify.py +0 -0
  74. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/errors.py +0 -0
  75. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/normalize.py +0 -0
  76. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/requests/views.py +0 -0
  77. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/__init__.py +0 -0
  78. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/_imports.py +0 -0
  79. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/capabilities.py +0 -0
  80. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/classify.py +0 -0
  81. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/errors.py +0 -0
  82. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/normalize.py +0 -0
  83. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/views.py +0 -0
  84. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/contrib/__init__.py +0 -0
  85. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/contrib/deadline.py +0 -0
  86. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/contrib/dishka.py +0 -0
  87. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/__init__.py +0 -0
  88. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/balancer/__init__.py +0 -0
  89. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/balancer/policy.py +0 -0
  90. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/capabilities.py +0 -0
  91. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/config.py +0 -0
  92. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/__init__.py +0 -0
  93. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/adapter.py +0 -0
  94. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/context.py +0 -0
  95. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/message.py +0 -0
  96. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/observability.py +0 -0
  97. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/contracts/settings.py +0 -0
  98. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/__init__.py +0 -0
  99. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/base.py +0 -0
  100. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/redirects.py +0 -0
  101. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/engine/suppress.py +0 -0
  102. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/model.py +0 -0
  103. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/options.py +0 -0
  104. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/plan.py +0 -0
  105. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/__init__.py +0 -0
  106. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/budget.py +0 -0
  107. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/circuit.py +0 -0
  108. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/concurrency.py +0 -0
  109. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/policy/timeout.py +0 -0
  110. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/registry.py +0 -0
  111. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/__init__.py +0 -0
  112. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/names.py +0 -0
  113. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/null.py +0 -0
  114. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/telemetry/redaction.py +0 -0
  115. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/testing/__init__.py +0 -0
  116. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/testing/doubles.py +0 -0
  117. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/core/testing/origin.py +0 -0
  118. {clientwright-0.2.0 → clientwright-0.2.1}/clientwright/py.typed +0 -0
  119. {clientwright-0.2.0 → clientwright-0.2.1}/pyproject.toml +0 -0
  120. {clientwright-0.2.0 → clientwright-0.2.1}/tests/__init__.py +0 -0
  121. {clientwright-0.2.0 → clientwright-0.2.1}/tests/conftest.py +0 -0
  122. {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/__init__.py +0 -0
  123. {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/drivers.py +0 -0
  124. {clientwright-0.2.0 → clientwright-0.2.1}/tests/helpers/views.py +0 -0
  125. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/__init__.py +0 -0
  126. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/__init__.py +0 -0
  127. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx2.py +0 -0
  128. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_async.py +0 -0
  129. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_sync.py +0 -0
  130. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_requests.py +0 -0
  131. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/adapters/test_urllib3.py +0 -0
  132. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/chaos/__init__.py +0 -0
  133. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/chaos/test_faults.py +0 -0
  134. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/chaos/test_origin_faults.py +0 -0
  135. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/conftest.py +0 -0
  136. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/contrib/__init__.py +0 -0
  137. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/contrib/test_deadline.py +0 -0
  138. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/contrib/test_dishka.py +0 -0
  139. {clientwright-0.2.0 → clientwright-0.2.1}/tests/integration/parity/__init__.py +0 -0
  140. {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/__init__.py +0 -0
  141. {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/conftest.py +0 -0
  142. {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/test_public_endpoints.py +0 -0
  143. {clientwright-0.2.0 → clientwright-0.2.1}/tests/live/test_tls_and_dns.py +0 -0
  144. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/__init__.py +0 -0
  145. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/__init__.py +0 -0
  146. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/__init__.py +0 -0
  147. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_normalize.py +0 -0
  148. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_trace.py +0 -0
  149. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_units.py +0 -0
  150. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/conftest.py +0 -0
  151. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/__init__.py +0 -0
  152. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/__init__.py +0 -0
  153. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/test_units.py +0 -0
  154. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/__init__.py +0 -0
  155. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_exports.py +0 -0
  156. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_otel.py +0 -0
  157. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_prometheus.py +0 -0
  158. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/__init__.py +0 -0
  159. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_normalize.py +0 -0
  160. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_units.py +0 -0
  161. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/test_bare_install.py +0 -0
  162. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_family_parity.py +0 -0
  163. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_shared.py +0 -0
  164. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/__init__.py +0 -0
  165. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_normalize.py +0 -0
  166. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_units.py +0 -0
  167. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/__init__.py +0 -0
  168. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline.py +0 -0
  169. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline_context.py +0 -0
  170. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/contrib/test_dishka.py +0 -0
  171. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/__init__.py +0 -0
  172. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/__init__.py +0 -0
  173. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_base.py +0 -0
  174. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_redirects.py +0 -0
  175. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/engine/test_suppress.py +0 -0
  176. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/__init__.py +0 -0
  177. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_budget.py +0 -0
  178. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit.py +0 -0
  179. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit_properties.py +0 -0
  180. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_concurrency.py +0 -0
  181. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry_properties.py +0 -0
  182. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout.py +0 -0
  183. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout_properties.py +0 -0
  184. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/__init__.py +0 -0
  185. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_masking.py +0 -0
  186. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction_properties.py +0 -0
  187. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_balancer.py +0 -0
  188. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities_properties.py +0 -0
  189. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_config.py +0 -0
  190. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_enum_coercion.py +0 -0
  191. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_errors.py +0 -0
  192. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_model.py +0 -0
  193. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_options.py +0 -0
  194. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_plan.py +0 -0
  195. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_registry.py +0 -0
  196. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/core/test_settings.py +0 -0
  197. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/test_docs_snippets.py +0 -0
  198. {clientwright-0.2.0 → clientwright-0.2.1}/tests/unit/test_sdk_imports_are_guarded.py +0 -0
  199. {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.0
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
- """The request body cannot be replayed, so the required repeat is impossible."""
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
- __all__ = ["validate_native"]
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 info.method not in config.methods and not info.idempotent:
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 None
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=None,
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",