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,206 @@
|
|
|
1
|
+
"""The requests adapter: builds real Session objects with the engine underneath.
|
|
2
|
+
|
|
3
|
+
The seam is a mounted ``HTTPAdapter`` subclass whose ``send()`` runs the
|
|
4
|
+
engine; the returned object is a genuine ``requests.Session``. Two constraints
|
|
5
|
+
this builder honors (verified against requests 2.32):
|
|
6
|
+
|
|
7
|
+
- requests has NO session-level timeout default - a bare ``session.get()``
|
|
8
|
+
hangs forever; the engine sends every attempt with its planned
|
|
9
|
+
``(connect, read)`` tuple, closing that hole;
|
|
10
|
+
- native retries execute INSIDE ``conn.urlopen``, invisible to metrics and the
|
|
11
|
+
breaker - they are pinned to ``Retry(0, read=False)`` and the engine owns
|
|
12
|
+
the loop.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ...core.capabilities import Capability
|
|
21
|
+
from ...core.config import ClientConfig, is_set, resolve
|
|
22
|
+
from ...core.contracts.adapter import AdapterDeps
|
|
23
|
+
from ...core.contracts.message import RequestView
|
|
24
|
+
from ...core.engine.suppress import is_suppressed, suppressed
|
|
25
|
+
from ...core.engine.sync import SyncAttemptEngine
|
|
26
|
+
from ...core.errors import UnsupportedCapabilityError
|
|
27
|
+
from ...core.model import ResolvedTimeouts
|
|
28
|
+
from ...core.native import validate_native
|
|
29
|
+
from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
|
|
30
|
+
from ...core.telemetry.emitter import ClientTelemetry
|
|
31
|
+
from ._imports import HTTPAdapter, requests, urllib3
|
|
32
|
+
from .capabilities import CAPABILITIES
|
|
33
|
+
from .errors import translate_call_error
|
|
34
|
+
from .normalize import SyncRequestsNormalizer
|
|
35
|
+
from .views import CALLER_TIMEOUT_ATTRIBUTE, RequestsRequestView
|
|
36
|
+
|
|
37
|
+
# requests has no timeout defaults at all: an UNSET phase stays unbounded and
|
|
38
|
+
# only the engine's total deadline clamps it.
|
|
39
|
+
_NATIVE_TIMEOUT_DEFAULTS = ResolvedTimeouts()
|
|
40
|
+
|
|
41
|
+
_SESSION_ATTRS = frozenset({"headers", "cookies", "auth", "params", "hooks", "stream", "trust_env"})
|
|
42
|
+
|
|
43
|
+
_RESERVED_SESSION_KEYS: Mapping[str, str] = {
|
|
44
|
+
"verify": "use ClientConfig.tls",
|
|
45
|
+
"cert": "use ClientConfig.tls.cert",
|
|
46
|
+
"proxies": "use ClientConfig.proxy",
|
|
47
|
+
"max_redirects": "use ClientConfig.max_redirects",
|
|
48
|
+
"adapters": "the mounted adapters belong to clientwright",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_RESERVED_ADAPTER_KEYS: Mapping[str, str] = {
|
|
52
|
+
"max_retries": "retries belong to clientwright; use ClientConfig.retry",
|
|
53
|
+
"pool_connections": "use ClientConfig.pool",
|
|
54
|
+
"pool_maxsize": "use ClientConfig.pool.max_connections_per_host",
|
|
55
|
+
"pool_block": "use ClientConfig.pool.max_connections_per_host",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EngineHTTPAdapter(HTTPAdapter):
|
|
60
|
+
"""HTTPAdapter whose send() is one engine-driven logical call."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, engine: SyncAttemptEngine, **kwargs: Any) -> None:
|
|
63
|
+
kwargs.setdefault("max_retries", urllib3.util.Retry(0, read=False))
|
|
64
|
+
super().__init__(**kwargs)
|
|
65
|
+
self._engine = engine
|
|
66
|
+
|
|
67
|
+
def send(
|
|
68
|
+
self,
|
|
69
|
+
request: requests.PreparedRequest,
|
|
70
|
+
stream: bool = False,
|
|
71
|
+
timeout: Any = None,
|
|
72
|
+
verify: bool | str = True,
|
|
73
|
+
cert: Any = None,
|
|
74
|
+
proxies: Any = None,
|
|
75
|
+
) -> requests.Response:
|
|
76
|
+
if is_suppressed():
|
|
77
|
+
# An outer clientwright layer owns instrumentation for this call.
|
|
78
|
+
return super().send(request, stream=stream, timeout=timeout, verify=verify, cert=cert, proxies=proxies)
|
|
79
|
+
setattr(request, CALLER_TIMEOUT_ATTRIBUTE, timeout)
|
|
80
|
+
|
|
81
|
+
def send_view(view: RequestView) -> requests.Response:
|
|
82
|
+
planned = view.planned if isinstance(view, RequestsRequestView) else None
|
|
83
|
+
attempt_timeout = (planned.connect, planned.read) if planned is not None else timeout
|
|
84
|
+
with suppressed():
|
|
85
|
+
return super(EngineHTTPAdapter, self).send(
|
|
86
|
+
view.native, stream=stream, timeout=attempt_timeout, verify=verify, cert=cert, proxies=proxies
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response = self._engine.run(request, send_view)
|
|
90
|
+
assert isinstance(response, requests.Response)
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RequestsAdapter:
|
|
95
|
+
name = "requests"
|
|
96
|
+
capabilities = CAPABILITIES
|
|
97
|
+
native_slots = frozenset({"session", "adapter"})
|
|
98
|
+
reserved_keys: Mapping[str, Mapping[str, str]] = {
|
|
99
|
+
"session": _RESERVED_SESSION_KEYS,
|
|
100
|
+
"adapter": _RESERVED_ADAPTER_KEYS,
|
|
101
|
+
}
|
|
102
|
+
allowed_keys: Mapping[str, frozenset[str] | None] = {"session": _SESSION_ATTRS, "adapter": None}
|
|
103
|
+
|
|
104
|
+
def _validated_native(self, config: ClientConfig) -> dict[str, dict[str, Any]]:
|
|
105
|
+
return validate_native(
|
|
106
|
+
config.native,
|
|
107
|
+
slots=self.native_slots,
|
|
108
|
+
reserved=self.reserved_keys,
|
|
109
|
+
allowed=self.allowed_keys,
|
|
110
|
+
signature_targets={"adapter": HTTPAdapter},
|
|
111
|
+
config_conflicts={},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _compile(self, config: ClientConfig) -> CallPlan:
|
|
115
|
+
applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE}
|
|
116
|
+
if resolve(config.pool.max_connections_per_host, None) is not None:
|
|
117
|
+
applied.add(Capability.POOL_LIMIT_PER_HOST)
|
|
118
|
+
if config.proxy is not None:
|
|
119
|
+
applied.add(Capability.PROXY)
|
|
120
|
+
emulated = {Capability.TIMEOUT_TOTAL}
|
|
121
|
+
dropped: dict[Capability, str] = {}
|
|
122
|
+
if is_set(config.timeout.attempt) and resolve(config.timeout.attempt, None) is not None:
|
|
123
|
+
dropped[Capability.TIMEOUT_ATTEMPT] = (
|
|
124
|
+
"sync engine cannot cancel a blocked attempt; only phase timeouts and the soft total apply"
|
|
125
|
+
)
|
|
126
|
+
if is_set(config.timeout.write) and resolve(config.timeout.write, None) is not None:
|
|
127
|
+
dropped[Capability.TIMEOUT_WRITE] = "requests has no write timeout"
|
|
128
|
+
if is_set(config.timeout.pool_acquire) and resolve(config.timeout.pool_acquire, None) is not None:
|
|
129
|
+
dropped[Capability.TIMEOUT_POOL] = "HTTPAdapter.send never forwards pool_timeout to urllib3"
|
|
130
|
+
if resolve(config.pool.http2, False):
|
|
131
|
+
dropped[Capability.HTTP2] = "requests speaks HTTP/1.1 only"
|
|
132
|
+
plan = compile_plan(
|
|
133
|
+
config,
|
|
134
|
+
CAPABILITIES,
|
|
135
|
+
native_timeout_defaults=_NATIVE_TIMEOUT_DEFAULTS,
|
|
136
|
+
applied_natively=frozenset(applied),
|
|
137
|
+
emulated=frozenset(emulated),
|
|
138
|
+
dropped=dropped,
|
|
139
|
+
)
|
|
140
|
+
plan.report.enforce(config.on_unsupported)
|
|
141
|
+
return plan
|
|
142
|
+
|
|
143
|
+
def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
|
|
144
|
+
if config.base_url is not None:
|
|
145
|
+
raise UnsupportedCapabilityError("requests has no base_url; pass absolute URLs (or use the httpx adapter)")
|
|
146
|
+
native = self._validated_native(config)
|
|
147
|
+
telemetry = ClientTelemetry(
|
|
148
|
+
service=config.service_name,
|
|
149
|
+
adapter=self.name,
|
|
150
|
+
seam=CAPABILITIES.seam,
|
|
151
|
+
config=config.observability,
|
|
152
|
+
metrics=deps.metrics,
|
|
153
|
+
tracer=deps.tracer,
|
|
154
|
+
)
|
|
155
|
+
runtime = deps.runtime or ClientRuntime.for_config(
|
|
156
|
+
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
157
|
+
)
|
|
158
|
+
plan = self._compile(config)
|
|
159
|
+
engine = SyncAttemptEngine(
|
|
160
|
+
plan=plan,
|
|
161
|
+
runtime=runtime,
|
|
162
|
+
telemetry=telemetry,
|
|
163
|
+
normalizer=SyncRequestsNormalizer(),
|
|
164
|
+
deps=deps,
|
|
165
|
+
translate=translate_call_error,
|
|
166
|
+
)
|
|
167
|
+
per_host = resolve(config.pool.max_connections_per_host, None)
|
|
168
|
+
adapter_kwargs: dict[str, Any] = {
|
|
169
|
+
"pool_maxsize": per_host if per_host is not None else resolve(config.pool.max_connections, 10) or 10,
|
|
170
|
+
"pool_block": per_host is not None,
|
|
171
|
+
}
|
|
172
|
+
adapter_kwargs.update(native.get("adapter", {}))
|
|
173
|
+
engine_adapter = EngineHTTPAdapter(engine, **adapter_kwargs)
|
|
174
|
+
session = requests.Session()
|
|
175
|
+
session.mount("http://", engine_adapter)
|
|
176
|
+
session.mount("https://", engine_adapter)
|
|
177
|
+
session.verify = config.tls.ca_bundle if config.tls.ca_bundle is not None else config.tls.verify
|
|
178
|
+
cert = config.tls.cert
|
|
179
|
+
if isinstance(cert, tuple) and len(cert) == 3:
|
|
180
|
+
raise UnsupportedCapabilityError("requests does not support a client cert with a key password")
|
|
181
|
+
session.cert = cert
|
|
182
|
+
session.max_redirects = config.max_redirects
|
|
183
|
+
if config.proxy is not None and config.proxy.url is not None:
|
|
184
|
+
session.proxies.update({"http": config.proxy.url, "https": config.proxy.url})
|
|
185
|
+
# config.proxy.from_env needs nothing: requests reads env proxies natively.
|
|
186
|
+
for attr, value in native.get("session", {}).items():
|
|
187
|
+
setattr(session, attr, value)
|
|
188
|
+
handle: ClientHandle[Any] = ClientHandle(
|
|
189
|
+
client=session,
|
|
190
|
+
adapter=self.name,
|
|
191
|
+
capabilities=CAPABILITIES,
|
|
192
|
+
report=plan.report,
|
|
193
|
+
runtime=runtime,
|
|
194
|
+
plan=plan,
|
|
195
|
+
close=session.close,
|
|
196
|
+
)
|
|
197
|
+
register_handle(session, handle)
|
|
198
|
+
return handle
|
|
199
|
+
|
|
200
|
+
def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
|
|
201
|
+
raise UnsupportedCapabilityError(
|
|
202
|
+
"requests is sync-only: there is no async client to build; use build_sync (or the httpx/aiohttp adapters)"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["EngineHTTPAdapter", "RequestsAdapter"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""requests capability declaration. Zero-dependency: never imports requests."""
|
|
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="requests",
|
|
16
|
+
seam="http_adapter",
|
|
17
|
+
granularity=SeamGranularity.HOP,
|
|
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.ABSENT,
|
|
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.TOTAL_TIMEOUT,
|
|
47
|
+
FailureKind.CONNECT_ERROR,
|
|
48
|
+
FailureKind.DNS_ERROR,
|
|
49
|
+
FailureKind.TLS_ERROR,
|
|
50
|
+
FailureKind.DISCONNECTED,
|
|
51
|
+
FailureKind.BODY_ERROR,
|
|
52
|
+
FailureKind.STATUS,
|
|
53
|
+
FailureKind.CIRCUIT_OPEN,
|
|
54
|
+
FailureKind.UNKNOWN,
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
collapses={
|
|
58
|
+
FailureKind.POOL_TIMEOUT: FailureKind.CONNECT_TIMEOUT,
|
|
59
|
+
FailureKind.PROTOCOL_ERROR: FailureKind.DISCONNECTED,
|
|
60
|
+
FailureKind.WRITE_TIMEOUT: FailureKind.TOTAL_TIMEOUT,
|
|
61
|
+
},
|
|
62
|
+
notes={
|
|
63
|
+
"sync_only": "requests has no async client; build_async raises.",
|
|
64
|
+
"no_session_timeout": (
|
|
65
|
+
"requests has NO session-level timeout default - a bare session.get() hangs forever. The engine "
|
|
66
|
+
"closes that hole: every attempt is sent with the planned (connect, read) tuple."
|
|
67
|
+
),
|
|
68
|
+
"timeout_pool": "HTTPAdapter.send never forwards pool_timeout; a saturated blocking pool waits inside connect.",
|
|
69
|
+
"pool_limits": (
|
|
70
|
+
"requests pools are per-host (pool_maxsize); there is no global cap - pool_connections is an LRU of "
|
|
71
|
+
"host pools, not a limit."
|
|
72
|
+
),
|
|
73
|
+
"keepalive": "Connections stay alive per urllib3 defaults; expiry and keep-alive count are not controllable.",
|
|
74
|
+
"base_url": "requests has no base_url; the build rejects a config that sets one.",
|
|
75
|
+
"per_call_options": "No request extensions; route/idempotency travel via the call_options() context manager.",
|
|
76
|
+
"protocol_error": "requests folds protocol violations into ConnectionError; they surface as disconnected.",
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__all__ = ["CAPABILITIES"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""requests exception -> FailureKind classification.
|
|
2
|
+
|
|
3
|
+
requests rewrites most urllib3 failures into its own hierarchy and folds
|
|
4
|
+
protocol violations into ConnectionError; the cause chain is inspected to tell
|
|
5
|
+
a DNS failure or a mid-stream disconnect from a refused connection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import http.client
|
|
11
|
+
import ssl
|
|
12
|
+
|
|
13
|
+
from ...core.model import FailureKind
|
|
14
|
+
from ._imports import requests, urllib3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cause_chain(exc: BaseException) -> list[BaseException]:
|
|
18
|
+
seen: set[int] = set()
|
|
19
|
+
chain: list[BaseException] = []
|
|
20
|
+
current: BaseException | None = exc
|
|
21
|
+
while current is not None and id(current) not in seen:
|
|
22
|
+
seen.add(id(current))
|
|
23
|
+
chain.append(current)
|
|
24
|
+
for nested in getattr(current, "args", ()):
|
|
25
|
+
if isinstance(nested, BaseException) and id(nested) not in seen:
|
|
26
|
+
chain.append(nested)
|
|
27
|
+
current = current.__cause__ or current.__context__
|
|
28
|
+
return chain
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _classify_connection_error(exc: requests.exceptions.ConnectionError) -> FailureKind:
|
|
32
|
+
for cause in _cause_chain(exc):
|
|
33
|
+
if isinstance(cause, urllib3.exceptions.NameResolutionError):
|
|
34
|
+
return FailureKind.DNS_ERROR
|
|
35
|
+
if isinstance(cause, (urllib3.exceptions.ProtocolError, http.client.RemoteDisconnected, ConnectionResetError)):
|
|
36
|
+
return FailureKind.DISCONNECTED
|
|
37
|
+
if isinstance(cause, (ssl.SSLError, ssl.CertificateError)):
|
|
38
|
+
return FailureKind.TLS_ERROR
|
|
39
|
+
return FailureKind.CONNECT_ERROR
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def classify_error(exc: BaseException) -> FailureKind:
|
|
43
|
+
if isinstance(exc, requests.exceptions.ConnectTimeout):
|
|
44
|
+
return FailureKind.CONNECT_TIMEOUT
|
|
45
|
+
if isinstance(exc, requests.exceptions.ReadTimeout):
|
|
46
|
+
return FailureKind.READ_TIMEOUT
|
|
47
|
+
if isinstance(exc, requests.exceptions.Timeout):
|
|
48
|
+
return FailureKind.TOTAL_TIMEOUT
|
|
49
|
+
if isinstance(exc, requests.exceptions.SSLError):
|
|
50
|
+
return FailureKind.TLS_ERROR
|
|
51
|
+
if isinstance(exc, requests.exceptions.ProxyError):
|
|
52
|
+
return FailureKind.CONNECT_ERROR
|
|
53
|
+
if isinstance(exc, (requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError)):
|
|
54
|
+
return FailureKind.BODY_ERROR
|
|
55
|
+
if isinstance(exc, requests.exceptions.ConnectionError):
|
|
56
|
+
return _classify_connection_error(exc)
|
|
57
|
+
if isinstance(exc, TimeoutError):
|
|
58
|
+
return FailureKind.TOTAL_TIMEOUT
|
|
59
|
+
return FailureKind.UNKNOWN
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["classify_error"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Kernel errors dual-inherited into the requests family.
|
|
2
|
+
|
|
3
|
+
A user's ``except requests.RequestException`` (or ``except requests.Timeout``)
|
|
4
|
+
keeps working when 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 requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RequestsCircuitOpenError(CircuitOpenError, requests.RequestException):
|
|
14
|
+
"""Circuit open, catchable as requests.RequestException."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RequestsDeadlineExceededError(DeadlineExceededError, requests.Timeout):
|
|
18
|
+
"""Total deadline exhausted, catchable as requests.Timeout."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RequestsTooManyRedirectsError(TooManyRedirectsError, requests.TooManyRedirects):
|
|
22
|
+
"""Owned redirect limit exceeded, catchable as requests.TooManyRedirects."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def translate_call_error(error: CallError) -> BaseException:
|
|
26
|
+
if isinstance(error, CircuitOpenError):
|
|
27
|
+
return RequestsCircuitOpenError(error.key, error.retry_after)
|
|
28
|
+
if isinstance(error, DeadlineExceededError):
|
|
29
|
+
return RequestsDeadlineExceededError(error.total)
|
|
30
|
+
if isinstance(error, TooManyRedirectsError):
|
|
31
|
+
return RequestsTooManyRedirectsError(error.hops)
|
|
32
|
+
return error
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"RequestsCircuitOpenError",
|
|
37
|
+
"RequestsDeadlineExceededError",
|
|
38
|
+
"RequestsTooManyRedirectsError",
|
|
39
|
+
"translate_call_error",
|
|
40
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Sync normalizer: the whole requests-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 ._imports import requests
|
|
12
|
+
from .classify import classify_error
|
|
13
|
+
from .views import RequestsRequestView, RequestsResponseView
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncRequestsNormalizer:
|
|
17
|
+
def wrap_request(self, native: Any) -> RequestsRequestView:
|
|
18
|
+
return RequestsRequestView(native)
|
|
19
|
+
|
|
20
|
+
def wrap_response(self, native: Any) -> RequestsResponseView:
|
|
21
|
+
return RequestsResponseView(native)
|
|
22
|
+
|
|
23
|
+
def classify_error(self, exc: BaseException) -> FailureKind:
|
|
24
|
+
return classify_error(exc)
|
|
25
|
+
|
|
26
|
+
def classify_response(self, response: ResponseView) -> Outcome:
|
|
27
|
+
return default_response_outcome(response)
|
|
28
|
+
|
|
29
|
+
def freeze(self, request: RequestView) -> bool:
|
|
30
|
+
"""Bytes and strings replay for free; seekable streams rewind, the rest do not."""
|
|
31
|
+
body = request.native.body
|
|
32
|
+
if body is None or isinstance(body, (bytes, str)):
|
|
33
|
+
return True
|
|
34
|
+
position = getattr(request.native, "_body_position", None)
|
|
35
|
+
return isinstance(position, int)
|
|
36
|
+
|
|
37
|
+
def rewind(self, request: RequestView) -> None:
|
|
38
|
+
body = request.native.body
|
|
39
|
+
if body is None or isinstance(body, (bytes, str)):
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
requests.utils.rewind_body(request.native)
|
|
43
|
+
except requests.exceptions.UnrewindableBodyError: # pragma: no cover - freeze() gated this
|
|
44
|
+
return None
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def discard(self, response: ResponseView) -> None:
|
|
48
|
+
"""MANDATORY before a repeat - otherwise the connection never returns to the pool."""
|
|
49
|
+
try:
|
|
50
|
+
response.native.close()
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None:
|
|
55
|
+
# Session.send consumes the body ABOVE this seam (unless stream=True);
|
|
56
|
+
# body read is not instrumented. Declared in capabilities.
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def conn_metrics(self, response: ResponseView) -> ConnMetrics | None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["SyncRequestsNormalizer"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""RequestView/ResponseView implementations over requests objects.
|
|
2
|
+
|
|
3
|
+
The caller's per-request ``timeout=`` is not stored on the PreparedRequest;
|
|
4
|
+
the engine adapter receives it as a ``send()`` argument and parks it on the
|
|
5
|
+
request object before wrapping, which is where the view picks it up.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import MutableMapping
|
|
11
|
+
from typing import cast
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from ...core.model import IDEMPOTENT_METHODS, RequestInfo, ResolvedTimeouts, origin_of
|
|
15
|
+
from ...core.options import current_call_options
|
|
16
|
+
from ._imports import requests
|
|
17
|
+
|
|
18
|
+
# Attribute the engine adapter sets on the PreparedRequest before wrapping.
|
|
19
|
+
CALLER_TIMEOUT_ATTRIBUTE = "_clientwright_caller_timeout"
|
|
20
|
+
|
|
21
|
+
_BODY_HEADERS = ("Content-Length", "Content-Type", "Transfer-Encoding")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def caller_timeouts_from(timeout: object) -> ResolvedTimeouts | None:
|
|
25
|
+
"""Translate requests' ``timeout=`` forms: float, or (connect, read)."""
|
|
26
|
+
if timeout is None:
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(timeout, (int, float)):
|
|
29
|
+
value = float(timeout)
|
|
30
|
+
return ResolvedTimeouts(connect=value, read=value)
|
|
31
|
+
if isinstance(timeout, tuple) and len(timeout) == 2:
|
|
32
|
+
connect, read = timeout
|
|
33
|
+
return ResolvedTimeouts(
|
|
34
|
+
connect=float(connect) if connect is not None else None,
|
|
35
|
+
read=float(read) if read is not None else None,
|
|
36
|
+
)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RequestsRequestView:
|
|
41
|
+
__slots__ = ("_caller", "_options", "_request", "planned")
|
|
42
|
+
|
|
43
|
+
def __init__(self, request: requests.PreparedRequest) -> None:
|
|
44
|
+
self._request = request
|
|
45
|
+
self._options = current_call_options()
|
|
46
|
+
self._caller = caller_timeouts_from(getattr(request, CALLER_TIMEOUT_ATTRIBUTE, None))
|
|
47
|
+
# The engine's per-attempt plan; the send callback translates it into
|
|
48
|
+
# the (connect, read) tuple HTTPAdapter.send accepts.
|
|
49
|
+
self.planned: ResolvedTimeouts | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def native(self) -> requests.PreparedRequest:
|
|
53
|
+
return self._request
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def info(self) -> RequestInfo:
|
|
57
|
+
request = self._request
|
|
58
|
+
method = request.method or "GET"
|
|
59
|
+
url = request.url or ""
|
|
60
|
+
options = self._options
|
|
61
|
+
route = options.route if options is not None else None
|
|
62
|
+
if options is not None and options.idempotent is not None:
|
|
63
|
+
idempotent = options.idempotent
|
|
64
|
+
else:
|
|
65
|
+
idempotent = method in IDEMPOTENT_METHODS
|
|
66
|
+
return RequestInfo(method=method, origin=origin_of(url), url=url, route=route, idempotent=idempotent)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def headers(self) -> MutableMapping[str, str]:
|
|
70
|
+
return cast(MutableMapping[str, str], self._request.headers)
|
|
71
|
+
|
|
72
|
+
def caller_timeouts(self) -> ResolvedTimeouts | None:
|
|
73
|
+
return self._caller
|
|
74
|
+
|
|
75
|
+
def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
|
|
76
|
+
self.planned = timeouts
|
|
77
|
+
|
|
78
|
+
def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
|
|
79
|
+
request = self._request
|
|
80
|
+
request.url = url
|
|
81
|
+
# requests never sets a Host header itself (http.client derives it from
|
|
82
|
+
# the URL), but a caller-set one must not leak to the new host.
|
|
83
|
+
if "Host" in request.headers:
|
|
84
|
+
request.headers["Host"] = urlsplit(url).netloc
|
|
85
|
+
if method is not None:
|
|
86
|
+
request.method = method
|
|
87
|
+
if drop_body:
|
|
88
|
+
for name in _BODY_HEADERS:
|
|
89
|
+
request.headers.pop(name, None)
|
|
90
|
+
request.body = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RequestsResponseView:
|
|
94
|
+
__slots__ = ("_response",)
|
|
95
|
+
|
|
96
|
+
def __init__(self, response: requests.Response) -> None:
|
|
97
|
+
self._response = response
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def native(self) -> requests.Response:
|
|
101
|
+
return self._response
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def status_code(self) -> int:
|
|
105
|
+
return self._response.status_code
|
|
106
|
+
|
|
107
|
+
def header(self, name: str) -> str | None:
|
|
108
|
+
value = self._response.headers.get(name)
|
|
109
|
+
return value if isinstance(value, str) else None
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def location(self) -> str | None:
|
|
113
|
+
return self.header("Location")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"CALLER_TIMEOUT_ATTRIBUTE",
|
|
118
|
+
"RequestsRequestView",
|
|
119
|
+
"RequestsResponseView",
|
|
120
|
+
"caller_timeouts_from",
|
|
121
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""urllib3 adapter (``[urllib3]`` extra): real PoolManager, engine underneath.
|
|
2
|
+
|
|
3
|
+
SDK-backed names resolve lazily (see ``clientwright.adapters._lazy``): importing
|
|
4
|
+
this package never imports urllib3, so the capabilities matrix stays
|
|
5
|
+
extras-free. The per-call channel comes from the zero-dependency core and is
|
|
6
|
+
therefore eager.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from ...core.options import CallOptions as CallOptions
|
|
14
|
+
from ...core.options import call_options as call_options
|
|
15
|
+
from .._lazy import lazy_attribute
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .adapter import Urllib3Adapter as Urllib3Adapter
|
|
19
|
+
from .adapter import translate_retry as translate_retry
|
|
20
|
+
from .capabilities import CAPABILITIES as CAPABILITIES
|
|
21
|
+
from .errors import Urllib3CircuitOpenError as Urllib3CircuitOpenError
|
|
22
|
+
from .errors import Urllib3DeadlineExceededError as Urllib3DeadlineExceededError
|
|
23
|
+
from .errors import Urllib3TooManyRedirectsError as Urllib3TooManyRedirectsError
|
|
24
|
+
|
|
25
|
+
_EXPORTS = {
|
|
26
|
+
"CAPABILITIES": "capabilities",
|
|
27
|
+
"Urllib3Adapter": "adapter",
|
|
28
|
+
"Urllib3CircuitOpenError": "errors",
|
|
29
|
+
"Urllib3DeadlineExceededError": "errors",
|
|
30
|
+
"Urllib3TooManyRedirectsError": "errors",
|
|
31
|
+
"translate_retry": "adapter",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def __getattr__(name: str) -> Any:
|
|
36
|
+
return lazy_attribute(__name__, globals(), _EXPORTS, name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CAPABILITIES",
|
|
41
|
+
"CallOptions",
|
|
42
|
+
"Urllib3Adapter",
|
|
43
|
+
"Urllib3CircuitOpenError",
|
|
44
|
+
"Urllib3DeadlineExceededError",
|
|
45
|
+
"Urllib3TooManyRedirectsError",
|
|
46
|
+
"call_options",
|
|
47
|
+
"translate_retry",
|
|
48
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Lazy import guard for the optional urllib3 stack.
|
|
2
|
+
|
|
3
|
+
Importing this module fails with a friendly message when the ``urllib3`` extra
|
|
4
|
+
is not installed. Every urllib3 submodule imports its third-party symbols from
|
|
5
|
+
here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
_INSTALL_HINT = "urllib3 support requires clientwright[urllib3]; install it."
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import urllib3
|
|
14
|
+
from urllib3.util import Retry, Timeout
|
|
15
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
16
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
17
|
+
|
|
18
|
+
__all__ = ["Retry", "Timeout", "urllib3"]
|