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.
- clientwright/__init__.py +179 -0
- clientwright/__version__.py +1 -0
- clientwright/adapters/__init__.py +3 -0
- clientwright/adapters/_httpx_shared.py +831 -0
- clientwright/adapters/_lazy.py +30 -0
- clientwright/adapters/aiohttp/__init__.py +45 -0
- clientwright/adapters/aiohttp/_imports.py +30 -0
- clientwright/adapters/aiohttp/adapter.py +236 -0
- clientwright/adapters/aiohttp/capabilities.py +81 -0
- clientwright/adapters/aiohttp/classify.py +59 -0
- clientwright/adapters/aiohttp/errors.py +57 -0
- clientwright/adapters/aiohttp/middleware.py +103 -0
- clientwright/adapters/aiohttp/normalize.py +64 -0
- clientwright/adapters/aiohttp/options.py +16 -0
- clientwright/adapters/aiohttp/trace.py +109 -0
- clientwright/adapters/aiohttp/views.py +108 -0
- clientwright/adapters/httpx/__init__.py +45 -0
- clientwright/adapters/httpx/_imports.py +17 -0
- clientwright/adapters/httpx/adapter.py +39 -0
- clientwright/adapters/httpx/capabilities.py +9 -0
- clientwright/adapters/httpx/classify.py +14 -0
- clientwright/adapters/httpx/errors.py +35 -0
- clientwright/adapters/httpx/normalize.py +27 -0
- clientwright/adapters/httpx/normalize_sync.py +27 -0
- clientwright/adapters/httpx/transport.py +40 -0
- clientwright/adapters/httpx/views.py +46 -0
- clientwright/adapters/httpx2/__init__.py +46 -0
- clientwright/adapters/httpx2/_imports.py +18 -0
- clientwright/adapters/httpx2/adapter.py +37 -0
- clientwright/adapters/httpx2/capabilities.py +9 -0
- clientwright/adapters/httpx2/classify.py +14 -0
- clientwright/adapters/httpx2/errors.py +36 -0
- clientwright/adapters/httpx2/normalize.py +27 -0
- clientwright/adapters/httpx2/normalize_sync.py +27 -0
- clientwright/adapters/httpx2/transport.py +35 -0
- clientwright/adapters/httpx2/views.py +45 -0
- clientwright/adapters/observability/__init__.py +26 -0
- clientwright/adapters/observability/_metrics/__init__.py +1 -0
- clientwright/adapters/observability/_metrics/prometheus.py +200 -0
- clientwright/adapters/observability/_tracing/__init__.py +3 -0
- clientwright/adapters/observability/_tracing/otel.py +61 -0
- clientwright/adapters/requests/__init__.py +45 -0
- clientwright/adapters/requests/_imports.py +21 -0
- clientwright/adapters/requests/adapter.py +206 -0
- clientwright/adapters/requests/capabilities.py +80 -0
- clientwright/adapters/requests/classify.py +62 -0
- clientwright/adapters/requests/errors.py +40 -0
- clientwright/adapters/requests/normalize.py +63 -0
- clientwright/adapters/requests/views.py +121 -0
- clientwright/adapters/urllib3/__init__.py +48 -0
- clientwright/adapters/urllib3/_imports.py +18 -0
- clientwright/adapters/urllib3/adapter.py +260 -0
- clientwright/adapters/urllib3/capabilities.py +86 -0
- clientwright/adapters/urllib3/classify.py +46 -0
- clientwright/adapters/urllib3/errors.py +55 -0
- clientwright/adapters/urllib3/normalize.py +51 -0
- clientwright/adapters/urllib3/views.py +119 -0
- clientwright/contrib/__init__.py +3 -0
- clientwright/contrib/deadline.py +107 -0
- clientwright/contrib/dishka.py +80 -0
- clientwright/core/__init__.py +6 -0
- clientwright/core/balancer/__init__.py +1 -0
- clientwright/core/balancer/policy.py +23 -0
- clientwright/core/capabilities.py +156 -0
- clientwright/core/config.py +367 -0
- clientwright/core/contracts/__init__.py +33 -0
- clientwright/core/contracts/adapter.py +62 -0
- clientwright/core/contracts/context.py +31 -0
- clientwright/core/contracts/message.py +118 -0
- clientwright/core/contracts/observability.py +92 -0
- clientwright/core/contracts/settings.py +140 -0
- clientwright/core/engine/__init__.py +1 -0
- clientwright/core/engine/aio.py +246 -0
- clientwright/core/engine/base.py +65 -0
- clientwright/core/engine/redirects.py +64 -0
- clientwright/core/engine/suppress.py +30 -0
- clientwright/core/engine/sync.py +241 -0
- clientwright/core/errors.py +113 -0
- clientwright/core/model.py +138 -0
- clientwright/core/native.py +84 -0
- clientwright/core/options.py +46 -0
- clientwright/core/plan.py +190 -0
- clientwright/core/policy/__init__.py +1 -0
- clientwright/core/policy/budget.py +76 -0
- clientwright/core/policy/circuit.py +169 -0
- clientwright/core/policy/concurrency.py +98 -0
- clientwright/core/policy/retry.py +80 -0
- clientwright/core/policy/timeout.py +64 -0
- clientwright/core/registry.py +75 -0
- clientwright/core/telemetry/__init__.py +6 -0
- clientwright/core/telemetry/emitter.py +163 -0
- clientwright/core/telemetry/names.py +61 -0
- clientwright/core/telemetry/null.py +89 -0
- clientwright/core/telemetry/redaction.py +24 -0
- clientwright/core/testing/__init__.py +7 -0
- clientwright/core/testing/doubles.py +107 -0
- clientwright/core/testing/origin.py +201 -0
- clientwright/py.typed +0 -0
- clientwright-0.1.0.dist-info/METADATA +210 -0
- clientwright-0.1.0.dist-info/RECORD +102 -0
- clientwright-0.1.0.dist-info/WHEEL +4 -0
- 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"]
|