clientwright 0.1.0__py3-none-any.whl

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 (102) hide show
  1. clientwright/__init__.py +179 -0
  2. clientwright/__version__.py +1 -0
  3. clientwright/adapters/__init__.py +3 -0
  4. clientwright/adapters/_httpx_shared.py +831 -0
  5. clientwright/adapters/_lazy.py +30 -0
  6. clientwright/adapters/aiohttp/__init__.py +45 -0
  7. clientwright/adapters/aiohttp/_imports.py +30 -0
  8. clientwright/adapters/aiohttp/adapter.py +236 -0
  9. clientwright/adapters/aiohttp/capabilities.py +81 -0
  10. clientwright/adapters/aiohttp/classify.py +59 -0
  11. clientwright/adapters/aiohttp/errors.py +57 -0
  12. clientwright/adapters/aiohttp/middleware.py +103 -0
  13. clientwright/adapters/aiohttp/normalize.py +64 -0
  14. clientwright/adapters/aiohttp/options.py +16 -0
  15. clientwright/adapters/aiohttp/trace.py +109 -0
  16. clientwright/adapters/aiohttp/views.py +108 -0
  17. clientwright/adapters/httpx/__init__.py +45 -0
  18. clientwright/adapters/httpx/_imports.py +17 -0
  19. clientwright/adapters/httpx/adapter.py +39 -0
  20. clientwright/adapters/httpx/capabilities.py +9 -0
  21. clientwright/adapters/httpx/classify.py +14 -0
  22. clientwright/adapters/httpx/errors.py +35 -0
  23. clientwright/adapters/httpx/normalize.py +27 -0
  24. clientwright/adapters/httpx/normalize_sync.py +27 -0
  25. clientwright/adapters/httpx/transport.py +40 -0
  26. clientwright/adapters/httpx/views.py +46 -0
  27. clientwright/adapters/httpx2/__init__.py +46 -0
  28. clientwright/adapters/httpx2/_imports.py +18 -0
  29. clientwright/adapters/httpx2/adapter.py +37 -0
  30. clientwright/adapters/httpx2/capabilities.py +9 -0
  31. clientwright/adapters/httpx2/classify.py +14 -0
  32. clientwright/adapters/httpx2/errors.py +36 -0
  33. clientwright/adapters/httpx2/normalize.py +27 -0
  34. clientwright/adapters/httpx2/normalize_sync.py +27 -0
  35. clientwright/adapters/httpx2/transport.py +35 -0
  36. clientwright/adapters/httpx2/views.py +45 -0
  37. clientwright/adapters/observability/__init__.py +26 -0
  38. clientwright/adapters/observability/_metrics/__init__.py +1 -0
  39. clientwright/adapters/observability/_metrics/prometheus.py +200 -0
  40. clientwright/adapters/observability/_tracing/__init__.py +3 -0
  41. clientwright/adapters/observability/_tracing/otel.py +61 -0
  42. clientwright/adapters/requests/__init__.py +45 -0
  43. clientwright/adapters/requests/_imports.py +21 -0
  44. clientwright/adapters/requests/adapter.py +206 -0
  45. clientwright/adapters/requests/capabilities.py +80 -0
  46. clientwright/adapters/requests/classify.py +62 -0
  47. clientwright/adapters/requests/errors.py +40 -0
  48. clientwright/adapters/requests/normalize.py +63 -0
  49. clientwright/adapters/requests/views.py +121 -0
  50. clientwright/adapters/urllib3/__init__.py +48 -0
  51. clientwright/adapters/urllib3/_imports.py +18 -0
  52. clientwright/adapters/urllib3/adapter.py +260 -0
  53. clientwright/adapters/urllib3/capabilities.py +86 -0
  54. clientwright/adapters/urllib3/classify.py +46 -0
  55. clientwright/adapters/urllib3/errors.py +55 -0
  56. clientwright/adapters/urllib3/normalize.py +51 -0
  57. clientwright/adapters/urllib3/views.py +119 -0
  58. clientwright/contrib/__init__.py +3 -0
  59. clientwright/contrib/deadline.py +107 -0
  60. clientwright/contrib/dishka.py +80 -0
  61. clientwright/core/__init__.py +6 -0
  62. clientwright/core/balancer/__init__.py +1 -0
  63. clientwright/core/balancer/policy.py +23 -0
  64. clientwright/core/capabilities.py +156 -0
  65. clientwright/core/config.py +367 -0
  66. clientwright/core/contracts/__init__.py +33 -0
  67. clientwright/core/contracts/adapter.py +62 -0
  68. clientwright/core/contracts/context.py +31 -0
  69. clientwright/core/contracts/message.py +118 -0
  70. clientwright/core/contracts/observability.py +92 -0
  71. clientwright/core/contracts/settings.py +140 -0
  72. clientwright/core/engine/__init__.py +1 -0
  73. clientwright/core/engine/aio.py +246 -0
  74. clientwright/core/engine/base.py +65 -0
  75. clientwright/core/engine/redirects.py +64 -0
  76. clientwright/core/engine/suppress.py +30 -0
  77. clientwright/core/engine/sync.py +241 -0
  78. clientwright/core/errors.py +113 -0
  79. clientwright/core/model.py +138 -0
  80. clientwright/core/native.py +84 -0
  81. clientwright/core/options.py +46 -0
  82. clientwright/core/plan.py +190 -0
  83. clientwright/core/policy/__init__.py +1 -0
  84. clientwright/core/policy/budget.py +76 -0
  85. clientwright/core/policy/circuit.py +169 -0
  86. clientwright/core/policy/concurrency.py +98 -0
  87. clientwright/core/policy/retry.py +80 -0
  88. clientwright/core/policy/timeout.py +64 -0
  89. clientwright/core/registry.py +75 -0
  90. clientwright/core/telemetry/__init__.py +6 -0
  91. clientwright/core/telemetry/emitter.py +163 -0
  92. clientwright/core/telemetry/names.py +61 -0
  93. clientwright/core/telemetry/null.py +89 -0
  94. clientwright/core/telemetry/redaction.py +24 -0
  95. clientwright/core/testing/__init__.py +7 -0
  96. clientwright/core/testing/doubles.py +107 -0
  97. clientwright/core/testing/origin.py +201 -0
  98. clientwright/py.typed +0 -0
  99. clientwright-0.1.0.dist-info/METADATA +210 -0
  100. clientwright-0.1.0.dist-info/RECORD +102 -0
  101. clientwright-0.1.0.dist-info/WHEEL +4 -0
  102. clientwright-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,260 @@
1
+ """The urllib3 adapter: builds real PoolManager objects with the engine underneath.
2
+
3
+ The seam is a per-instance ``urlopen`` injected on a GENUINE ``PoolManager``
4
+ (``type(client) is urllib3.PoolManager`` holds - no subclass); recursive
5
+ native calls and lower clientwright layers pass straight through via the
6
+ suppression ContextVar.
7
+
8
+ This is also the documented home of ``RetryMode.DELEGATED``: urllib3's Retry
9
+ is the richest native implementation, so a config in DELEGATED mode is
10
+ translated into ``urllib3.util.Retry`` and the native machinery runs the loop
11
+ below the seam - with per-attempt metrics honestly absent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from typing import Any
18
+
19
+ from ...core.capabilities import Capability
20
+ from ...core.config import ClientConfig, RetryConfig, RetryMode, is_set, resolve
21
+ from ...core.contracts.adapter import AdapterDeps
22
+ from ...core.contracts.message import RequestView
23
+ from ...core.engine.suppress import is_suppressed, suppressed
24
+ from ...core.engine.sync import SyncAttemptEngine
25
+ from ...core.errors import UnsupportedCapabilityError
26
+ from ...core.model import ResolvedTimeouts
27
+ from ...core.native import validate_native
28
+ from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
29
+ from ...core.policy.timeout import base_timeouts
30
+ from ...core.telemetry.emitter import ClientTelemetry
31
+ from ._imports import Retry, Timeout, urllib3
32
+ from .capabilities import CAPABILITIES
33
+ from .errors import translate_call_error
34
+ from .normalize import SyncUrllib3Normalizer
35
+ from .views import Urllib3CallSpec, Urllib3RequestView
36
+
37
+ # urllib3 defaults timeouts to socket.getdefaulttimeout() (usually unbounded).
38
+ _NATIVE_TIMEOUT_DEFAULTS = ResolvedTimeouts()
39
+
40
+ _RESERVED_MANAGER_KEYS: Mapping[str, str] = {
41
+ "timeout": "use ClientConfig.timeout",
42
+ "retries": "retries belong to clientwright; use ClientConfig.retry (mode=DELEGATED for native execution)",
43
+ "maxsize": "use ClientConfig.pool.max_connections_per_host",
44
+ "block": "use ClientConfig.pool.max_connections_per_host",
45
+ "cert_reqs": "use ClientConfig.tls",
46
+ "ca_certs": "use ClientConfig.tls.ca_bundle",
47
+ "cert_file": "use ClientConfig.tls.cert",
48
+ "key_file": "use ClientConfig.tls.cert",
49
+ }
50
+
51
+
52
+ def translate_retry(retry: RetryConfig, max_redirects: int) -> Retry:
53
+ """Express RetryConfig as urllib3.util.Retry for DELEGATED mode.
54
+
55
+ urllib3's exponential backoff base is fixed at 2; ``multiplier`` is not
56
+ translated (declared in the capabilities notes).
57
+ """
58
+ return Retry(
59
+ total=retry.max_attempts - 1,
60
+ redirect=max_redirects,
61
+ backoff_factor=retry.initial_backoff,
62
+ backoff_max=retry.max_backoff,
63
+ backoff_jitter=retry.jitter * retry.initial_backoff,
64
+ status_forcelist=set(retry.retryable_status),
65
+ allowed_methods=frozenset(retry.methods),
66
+ respect_retry_after_header=retry.respect_retry_after,
67
+ raise_on_status=False,
68
+ raise_on_redirect=False,
69
+ )
70
+
71
+
72
+ class EngineUrlopen:
73
+ """The instance-injected urlopen: one call in, one engine-driven logical call out."""
74
+
75
+ __slots__ = ("_blocking", "_delegated_retry", "_engine", "_manager")
76
+
77
+ def __init__(
78
+ self,
79
+ manager: urllib3.PoolManager,
80
+ engine: SyncAttemptEngine,
81
+ delegated_retry: Retry | None,
82
+ blocking_pool: bool,
83
+ ) -> None:
84
+ self._manager = manager
85
+ self._engine = engine
86
+ self._delegated_retry = delegated_retry
87
+ self._blocking = blocking_pool
88
+
89
+ def _native_urlopen(self, method: str, url: str, **kw: Any) -> Any:
90
+ return type(self._manager).urlopen(self._manager, method, url, **kw)
91
+
92
+ def _send(self, view: RequestView) -> Any:
93
+ assert isinstance(view, Urllib3RequestView)
94
+ spec = view.native
95
+ kw = dict(spec.kw)
96
+ kw["headers"] = dict(spec.headers)
97
+ kw["body"] = spec.body
98
+ if self._delegated_retry is not None:
99
+ # Native machinery owns retries AND redirects below the seam.
100
+ kw["retries"] = self._delegated_retry
101
+ kw.setdefault("redirect", True)
102
+ else:
103
+ # The engine owns the loop: raw errors, no native retry, no native
104
+ # redirect following (hops re-enter through retarget()).
105
+ kw["retries"] = False
106
+ kw["redirect"] = False
107
+ planned = spec.planned
108
+ if planned is not None:
109
+ kw["timeout"] = Timeout(connect=planned.connect, read=planned.read)
110
+ if self._blocking and planned.pool_acquire is not None:
111
+ kw.setdefault("pool_timeout", planned.pool_acquire)
112
+ with suppressed():
113
+ return self._native_urlopen(spec.method, spec.url, **kw)
114
+
115
+ def __call__(self, method: str, url: str, redirect: bool = True, **kw: Any) -> Any:
116
+ if is_suppressed():
117
+ # A recursive native hop, or an outer clientwright layer that owns
118
+ # instrumentation: pass straight through.
119
+ return self._native_urlopen(method, url, redirect=redirect, **kw)
120
+ headers_arg = kw.pop("headers", None)
121
+ headers = dict(headers_arg) if headers_arg is not None else dict(self._manager.headers or {})
122
+ body = kw.pop("body", None)
123
+ spec = Urllib3CallSpec(method, url, headers, body, kw)
124
+ return self._engine.run(spec, self._send)
125
+
126
+
127
+ class Urllib3Adapter:
128
+ name = "urllib3"
129
+ capabilities = CAPABILITIES
130
+ native_slots = frozenset({"manager"})
131
+ reserved_keys: Mapping[str, Mapping[str, str]] = {"manager": _RESERVED_MANAGER_KEYS}
132
+ allowed_keys: Mapping[str, frozenset[str] | None] = {"manager": None}
133
+
134
+ def _validated_native(self, config: ClientConfig) -> dict[str, dict[str, Any]]:
135
+ return validate_native(
136
+ config.native,
137
+ slots=self.native_slots,
138
+ reserved=self.reserved_keys,
139
+ allowed=self.allowed_keys,
140
+ signature_targets={"manager": urllib3.HTTPConnectionPool},
141
+ config_conflicts={},
142
+ )
143
+
144
+ def _compile(self, config: ClientConfig) -> CallPlan:
145
+ per_host = resolve(config.pool.max_connections_per_host, None)
146
+ applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE}
147
+ if per_host is not None:
148
+ applied |= {Capability.POOL_LIMIT_PER_HOST, Capability.TIMEOUT_POOL}
149
+ if config.proxy is not None and config.proxy.url is not None:
150
+ applied.add(Capability.PROXY)
151
+ emulated = {Capability.TIMEOUT_TOTAL}
152
+ dropped: dict[Capability, str] = {}
153
+ if is_set(config.timeout.attempt) and resolve(config.timeout.attempt, None) is not None:
154
+ dropped[Capability.TIMEOUT_ATTEMPT] = (
155
+ "sync engine cannot cancel a blocked attempt; only phase timeouts and the soft total apply"
156
+ )
157
+ if is_set(config.timeout.write) and resolve(config.timeout.write, None) is not None:
158
+ dropped[Capability.TIMEOUT_WRITE] = "urllib3 has no write timeout"
159
+ pool_acquire_set = (
160
+ is_set(config.timeout.pool_acquire) and resolve(config.timeout.pool_acquire, None) is not None
161
+ )
162
+ if per_host is None and pool_acquire_set:
163
+ dropped[Capability.TIMEOUT_POOL] = "pool_timeout needs a blocking pool; set pool.max_connections_per_host"
164
+ if resolve(config.pool.http2, False):
165
+ dropped[Capability.HTTP2] = "urllib3's HTTP/2 support is experimental and not wired here"
166
+ if config.proxy is not None and config.proxy.from_env:
167
+ dropped[Capability.PROXY] = "urllib3 does not read environment proxies; pass ProxyConfig.url"
168
+ plan = compile_plan(
169
+ config,
170
+ CAPABILITIES,
171
+ native_timeout_defaults=_NATIVE_TIMEOUT_DEFAULTS,
172
+ applied_natively=frozenset(applied),
173
+ emulated=frozenset(emulated),
174
+ dropped=dropped,
175
+ )
176
+ plan.report.enforce(config.on_unsupported)
177
+ return plan
178
+
179
+ def _manager_kwargs(self, config: ClientConfig, native_manager: dict[str, Any]) -> dict[str, Any]:
180
+ per_host = resolve(config.pool.max_connections_per_host, None)
181
+ base = base_timeouts(config.timeout, _NATIVE_TIMEOUT_DEFAULTS)
182
+ kwargs: dict[str, Any] = {
183
+ "maxsize": per_host if per_host is not None else resolve(config.pool.max_connections, 10) or 10,
184
+ "block": per_host is not None,
185
+ # A defensive default for calls that bypass the engine (suppressed
186
+ # native hops); the engine overrides timeout per attempt anyway.
187
+ "timeout": Timeout(connect=base.connect, read=base.read),
188
+ }
189
+ tls = config.tls
190
+ if not tls.verify:
191
+ kwargs["cert_reqs"] = "CERT_NONE"
192
+ if tls.ca_bundle is not None:
193
+ kwargs["ca_certs"] = tls.ca_bundle
194
+ if tls.cert is not None:
195
+ if isinstance(tls.cert, str):
196
+ kwargs["cert_file"] = tls.cert
197
+ else:
198
+ kwargs["cert_file"] = tls.cert[0]
199
+ kwargs["key_file"] = tls.cert[1]
200
+ kwargs.update(native_manager)
201
+ return kwargs
202
+
203
+ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
204
+ if config.base_url is not None:
205
+ raise UnsupportedCapabilityError("urllib3 has no base_url; pass absolute URLs (or use the httpx adapter)")
206
+ native = self._validated_native(config)
207
+ telemetry = ClientTelemetry(
208
+ service=config.service_name,
209
+ adapter=self.name,
210
+ seam=CAPABILITIES.seam,
211
+ config=config.observability,
212
+ metrics=deps.metrics,
213
+ tracer=deps.tracer,
214
+ )
215
+ runtime = deps.runtime or ClientRuntime.for_config(
216
+ config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
217
+ )
218
+ plan = self._compile(config)
219
+ engine = SyncAttemptEngine(
220
+ plan=plan,
221
+ runtime=runtime,
222
+ telemetry=telemetry,
223
+ normalizer=SyncUrllib3Normalizer(),
224
+ deps=deps,
225
+ translate=translate_call_error,
226
+ )
227
+ manager_kwargs = self._manager_kwargs(config, native.get("manager", {}))
228
+ manager: urllib3.PoolManager
229
+ if config.proxy is not None and config.proxy.url is not None:
230
+ manager = urllib3.ProxyManager(config.proxy.url, **manager_kwargs)
231
+ else:
232
+ manager = urllib3.PoolManager(**manager_kwargs)
233
+ delegated = config.retry is not None and config.retry.mode is RetryMode.DELEGATED
234
+ delegated_retry = (
235
+ translate_retry(config.retry, config.max_redirects) if delegated and config.retry is not None else None
236
+ )
237
+ per_host = resolve(config.pool.max_connections_per_host, None)
238
+ # The instance attribute shadows the class method for every entry point
239
+ # (request/request_encode_* all funnel into self.urlopen), while
240
+ # type(manager) stays the genuine native class.
241
+ manager.urlopen = EngineUrlopen(manager, engine, delegated_retry, per_host is not None) # type: ignore[method-assign]
242
+ handle: ClientHandle[Any] = ClientHandle(
243
+ client=manager,
244
+ adapter=self.name,
245
+ capabilities=CAPABILITIES,
246
+ report=plan.report,
247
+ runtime=runtime,
248
+ plan=plan,
249
+ close=manager.clear,
250
+ )
251
+ register_handle(manager, handle)
252
+ return handle
253
+
254
+ def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
255
+ raise UnsupportedCapabilityError(
256
+ "urllib3 is sync-only: there is no async client to build; use build_sync (or the httpx/aiohttp adapters)"
257
+ )
258
+
259
+
260
+ __all__ = ["EngineUrlopen", "Urllib3Adapter", "translate_retry"]
@@ -0,0 +1,86 @@
1
+ """urllib3 capability declaration. Zero-dependency: never imports urllib3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.capabilities import (
6
+ AdapterCapabilities,
7
+ Capability,
8
+ DurationBoundary,
9
+ SeamGranularity,
10
+ Support,
11
+ )
12
+ from ...core.model import FailureKind
13
+
14
+ CAPABILITIES = AdapterCapabilities(
15
+ adapter="urllib3",
16
+ seam="urlopen",
17
+ granularity=SeamGranularity.LOGICAL,
18
+ boundary=DurationBoundary.HEADERS,
19
+ support={
20
+ Capability.TIMEOUT_TOTAL: Support.EMULATED,
21
+ Capability.TIMEOUT_ATTEMPT: Support.ABSENT,
22
+ Capability.TIMEOUT_CONNECT: Support.NATIVE,
23
+ Capability.TIMEOUT_READ: Support.NATIVE,
24
+ Capability.TIMEOUT_WRITE: Support.ABSENT,
25
+ Capability.TIMEOUT_POOL: Support.NATIVE,
26
+ Capability.DEADLINE_HARD: Support.ABSENT,
27
+ Capability.POOL_LIMIT_TOTAL: Support.ABSENT,
28
+ Capability.POOL_LIMIT_PER_HOST: Support.NATIVE,
29
+ Capability.KEEPALIVE: Support.DEGRADED,
30
+ Capability.POOL_METRICS: Support.ABSENT,
31
+ Capability.CONN_METRICS: Support.ABSENT,
32
+ Capability.REDIRECTS_OWNABLE: Support.NATIVE,
33
+ Capability.NATIVE_RETRY_DISABLEABLE: Support.NATIVE,
34
+ Capability.PER_CALL_OPTIONS: Support.EMULATED,
35
+ Capability.RETROFIT: Support.ABSENT,
36
+ Capability.EXACT_NATIVE_TYPE: Support.NATIVE,
37
+ Capability.BALANCER: Support.ABSENT,
38
+ Capability.HTTP2: Support.ABSENT,
39
+ Capability.HTTP3: Support.ABSENT,
40
+ Capability.PROXY: Support.NATIVE,
41
+ },
42
+ emits=frozenset(
43
+ {
44
+ FailureKind.CONNECT_TIMEOUT,
45
+ FailureKind.READ_TIMEOUT,
46
+ FailureKind.POOL_TIMEOUT,
47
+ FailureKind.TOTAL_TIMEOUT,
48
+ FailureKind.CONNECT_ERROR,
49
+ FailureKind.DNS_ERROR,
50
+ FailureKind.TLS_ERROR,
51
+ FailureKind.DISCONNECTED,
52
+ FailureKind.BODY_ERROR,
53
+ FailureKind.STATUS,
54
+ FailureKind.CIRCUIT_OPEN,
55
+ FailureKind.UNKNOWN,
56
+ }
57
+ ),
58
+ collapses={
59
+ FailureKind.PROTOCOL_ERROR: FailureKind.DISCONNECTED,
60
+ FailureKind.WRITE_TIMEOUT: FailureKind.TOTAL_TIMEOUT,
61
+ },
62
+ notes={
63
+ "sync_only": "urllib3 has no async client; build_async raises.",
64
+ "seam": (
65
+ "The engine is injected as an INSTANCE urlopen on a genuine PoolManager (type(client) is "
66
+ "urllib3.PoolManager); recursive native redirect hops re-enter it and pass straight through."
67
+ ),
68
+ "delegated_retries": (
69
+ "RetryMode.DELEGATED is the documented carve-out here: RetryConfig is translated into "
70
+ "urllib3.util.Retry and the native machinery runs the loop below the seam. Per-attempt metrics are "
71
+ "honestly absent (attempts live inside conn.urlopen); observe them via response.retries.history. "
72
+ "The backoff multiplier is fixed at 2 by urllib3; RetryConfig.multiplier is not translated."
73
+ ),
74
+ "timeout_pool": (
75
+ "The only adapter where the pool timeout is real: pool_timeout is injected per call when "
76
+ "max_connections_per_host makes the pool blocking."
77
+ ),
78
+ "pool_limits": "num_pools is an LRU cache of host pools, not a global cap; maxsize+block cap per host.",
79
+ "keepalive": "Connections are kept alive by pool defaults; expiry and keep-alive count are not controllable.",
80
+ "per_call_options": "No request object; route/idempotency travel via the call_options() context manager.",
81
+ "base_url": "urllib3 has no base_url; the build rejects a config that sets one.",
82
+ "proxy": "An explicit proxy builds a genuine urllib3.ProxyManager; env proxies are not read (dropped).",
83
+ },
84
+ )
85
+
86
+ __all__ = ["CAPABILITIES"]
@@ -0,0 +1,46 @@
1
+ """urllib3 exception -> FailureKind classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.model import FailureKind
6
+ from ._imports import urllib3
7
+
8
+ _EXC = urllib3.exceptions
9
+
10
+
11
+ def classify_error(exc: BaseException) -> FailureKind:
12
+ if isinstance(exc, _EXC.MaxRetryError):
13
+ # Native retries exhausted (DELEGATED mode): the last real failure is
14
+ # the reason; classify that, not the wrapper.
15
+ if exc.reason is not None:
16
+ return classify_error(exc.reason)
17
+ return FailureKind.UNKNOWN
18
+ # urllib3 v2 subclasses NameResolutionError < NewConnectionError <
19
+ # ConnectTimeoutError, so the ladder MUST test most-specific first or DNS
20
+ # failures and refused connections masquerade as connect timeouts.
21
+ if isinstance(exc, _EXC.NameResolutionError):
22
+ return FailureKind.DNS_ERROR
23
+ if isinstance(exc, _EXC.NewConnectionError):
24
+ return FailureKind.CONNECT_ERROR
25
+ if isinstance(exc, _EXC.ConnectTimeoutError):
26
+ return FailureKind.CONNECT_TIMEOUT
27
+ if isinstance(exc, _EXC.ReadTimeoutError):
28
+ return FailureKind.READ_TIMEOUT
29
+ if isinstance(exc, _EXC.ProxyError):
30
+ return FailureKind.CONNECT_ERROR
31
+ if isinstance(exc, _EXC.SSLError):
32
+ return FailureKind.TLS_ERROR
33
+ if isinstance(exc, _EXC.ProtocolError):
34
+ return FailureKind.DISCONNECTED
35
+ if isinstance(exc, _EXC.DecodeError):
36
+ return FailureKind.BODY_ERROR
37
+ if isinstance(exc, _EXC.EmptyPoolError):
38
+ return FailureKind.POOL_TIMEOUT
39
+ if isinstance(exc, _EXC.PoolError):
40
+ return FailureKind.POOL_TIMEOUT
41
+ if isinstance(exc, (_EXC.TimeoutError, TimeoutError)):
42
+ return FailureKind.TOTAL_TIMEOUT
43
+ return FailureKind.UNKNOWN
44
+
45
+
46
+ __all__ = ["classify_error"]
@@ -0,0 +1,55 @@
1
+ """Kernel errors dual-inherited into the urllib3 family.
2
+
3
+ A user's ``except urllib3.exceptions.HTTPError`` keeps working when
4
+ clientwright raises on its own authority.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
10
+ from ._imports import urllib3
11
+
12
+
13
+ class Urllib3CircuitOpenError(CircuitOpenError, urllib3.exceptions.HTTPError):
14
+ """Circuit open, catchable as urllib3.exceptions.HTTPError."""
15
+
16
+
17
+ class Urllib3DeadlineExceededError(DeadlineExceededError, urllib3.exceptions.TimeoutError):
18
+ """Total deadline exhausted, catchable as urllib3.exceptions.TimeoutError."""
19
+
20
+
21
+ class Urllib3TooManyRedirectsError(TooManyRedirectsError, urllib3.exceptions.MaxRetryError):
22
+ """Owned redirect limit exceeded, catchable as urllib3.exceptions.MaxRetryError.
23
+
24
+ ``MaxRetryError.__init__`` demands pool/url positionally, which breaks the
25
+ cooperative super chain - so this class initializes by hand and pins the
26
+ attributes urllib3's ``__str__`` machinery expects.
27
+ """
28
+
29
+ def __init__(self, hops: int) -> None:
30
+ Exception.__init__(self, f"Exceeded {hops} redirect hops")
31
+ self.hops = hops
32
+ self.pool = None # type: ignore[assignment]
33
+ self.url = ""
34
+ self.reason = None
35
+
36
+ def __str__(self) -> str:
37
+ return f"Exceeded {self.hops} redirect hops"
38
+
39
+
40
+ def translate_call_error(error: CallError) -> BaseException:
41
+ if isinstance(error, CircuitOpenError):
42
+ return Urllib3CircuitOpenError(error.key, error.retry_after)
43
+ if isinstance(error, DeadlineExceededError):
44
+ return Urllib3DeadlineExceededError(error.total)
45
+ if isinstance(error, TooManyRedirectsError):
46
+ return Urllib3TooManyRedirectsError(error.hops)
47
+ return error
48
+
49
+
50
+ __all__ = [
51
+ "Urllib3CircuitOpenError",
52
+ "Urllib3DeadlineExceededError",
53
+ "Urllib3TooManyRedirectsError",
54
+ "translate_call_error",
55
+ ]
@@ -0,0 +1,51 @@
1
+ """Sync normalizer: the whole urllib3-specific contract for the sync engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from ...core.contracts.message import RequestView, ResponseView
9
+ from ...core.engine.base import default_response_outcome
10
+ from ...core.model import ConnMetrics, FailureKind, Outcome
11
+ from .classify import classify_error
12
+ from .views import Urllib3RequestView, Urllib3ResponseView
13
+
14
+
15
+ class SyncUrllib3Normalizer:
16
+ def wrap_request(self, native: Any) -> Urllib3RequestView:
17
+ return Urllib3RequestView(native)
18
+
19
+ def wrap_response(self, native: Any) -> Urllib3ResponseView:
20
+ return Urllib3ResponseView(native)
21
+
22
+ def classify_error(self, exc: BaseException) -> FailureKind:
23
+ return classify_error(exc)
24
+
25
+ def classify_response(self, response: ResponseView) -> Outcome:
26
+ return default_response_outcome(response)
27
+
28
+ def freeze(self, request: RequestView) -> bool:
29
+ body = request.native.body
30
+ return body is None or isinstance(body, (bytes, str))
31
+
32
+ def rewind(self, request: RequestView) -> None:
33
+ return None
34
+
35
+ def discard(self, response: ResponseView) -> None:
36
+ """MANDATORY before a repeat - otherwise the connection never returns to the pool."""
37
+ try:
38
+ response.native.drain_conn()
39
+ except Exception:
40
+ return None
41
+
42
+ def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None:
43
+ # urlopen preloads the body by default; streaming reads happen above
44
+ # the seam and are not instrumented. Declared in capabilities.
45
+ return None
46
+
47
+ def conn_metrics(self, response: ResponseView) -> ConnMetrics | None:
48
+ return None
49
+
50
+
51
+ __all__ = ["SyncUrllib3Normalizer"]
@@ -0,0 +1,119 @@
1
+ """RequestView/ResponseView implementations over urllib3 calls.
2
+
3
+ urllib3 has no request object with stable identity - the seam is the
4
+ ``urlopen(method, url, **kw)`` signature. The adapter synthesizes a mutable
5
+ call spec and the view wraps that; the send callback translates it back into
6
+ positional/keyword arguments.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import MutableMapping
12
+ from typing import Any
13
+
14
+ from ...core.model import IDEMPOTENT_METHODS, RequestInfo, ResolvedTimeouts, origin_of
15
+ from ...core.options import current_call_options
16
+ from ._imports import Timeout
17
+
18
+
19
+ class Urllib3CallSpec:
20
+ """The mutable identity of one logical urlopen call."""
21
+
22
+ __slots__ = ("body", "headers", "kw", "method", "planned", "url")
23
+
24
+ def __init__(self, method: str, url: str, headers: dict[str, str], body: Any, kw: dict[str, Any]) -> None:
25
+ self.method = method
26
+ self.url = url
27
+ self.headers = headers
28
+ self.body = body
29
+ self.kw = kw
30
+ self.planned: ResolvedTimeouts | None = None
31
+
32
+
33
+ def caller_timeouts_from(timeout: object) -> ResolvedTimeouts | None:
34
+ """Translate urllib3's ``timeout=`` forms: float or urllib3.util.Timeout."""
35
+ if timeout is None:
36
+ return None
37
+ if isinstance(timeout, (int, float)):
38
+ value = float(timeout)
39
+ return ResolvedTimeouts(connect=value, read=value)
40
+ if isinstance(timeout, Timeout):
41
+ connect = timeout.connect_timeout
42
+ read = timeout.read_timeout
43
+ return ResolvedTimeouts(
44
+ connect=float(connect) if isinstance(connect, (int, float)) else None,
45
+ read=float(read) if isinstance(read, (int, float)) else None,
46
+ )
47
+ return None
48
+
49
+
50
+ class Urllib3RequestView:
51
+ __slots__ = ("_caller", "_options", "_spec")
52
+
53
+ def __init__(self, spec: Urllib3CallSpec) -> None:
54
+ self._spec = spec
55
+ self._options = current_call_options()
56
+ self._caller = caller_timeouts_from(spec.kw.get("timeout"))
57
+
58
+ @property
59
+ def native(self) -> Urllib3CallSpec:
60
+ return self._spec
61
+
62
+ @property
63
+ def info(self) -> RequestInfo:
64
+ spec = self._spec
65
+ options = self._options
66
+ route = options.route if options is not None else None
67
+ if options is not None and options.idempotent is not None:
68
+ idempotent = options.idempotent
69
+ else:
70
+ idempotent = spec.method in IDEMPOTENT_METHODS
71
+ return RequestInfo(
72
+ method=spec.method, origin=origin_of(spec.url), url=spec.url, route=route, idempotent=idempotent
73
+ )
74
+
75
+ @property
76
+ def headers(self) -> MutableMapping[str, str]:
77
+ return self._spec.headers
78
+
79
+ def caller_timeouts(self) -> ResolvedTimeouts | None:
80
+ return self._caller
81
+
82
+ def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
83
+ self._spec.planned = timeouts
84
+
85
+ def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
86
+ spec = self._spec
87
+ spec.url = url
88
+ if method is not None:
89
+ spec.method = method
90
+ if drop_body:
91
+ for name in ("Content-Length", "Content-Type", "Transfer-Encoding"):
92
+ spec.headers.pop(name, None)
93
+ spec.body = None
94
+
95
+
96
+ class Urllib3ResponseView:
97
+ __slots__ = ("_response",)
98
+
99
+ def __init__(self, response: Any) -> None:
100
+ self._response = response
101
+
102
+ @property
103
+ def native(self) -> Any:
104
+ return self._response
105
+
106
+ @property
107
+ def status_code(self) -> int:
108
+ return int(self._response.status)
109
+
110
+ def header(self, name: str) -> str | None:
111
+ value = self._response.headers.get(name)
112
+ return value if isinstance(value, str) else None
113
+
114
+ @property
115
+ def location(self) -> str | None:
116
+ return self.header("Location")
117
+
118
+
119
+ __all__ = ["Urllib3CallSpec", "Urllib3RequestView", "Urllib3ResponseView", "caller_timeouts_from"]
@@ -0,0 +1,3 @@
1
+ """Integrations with org infrastructure, each behind its own extra."""
2
+
3
+ __all__: list[str] = []