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,831 @@
|
|
|
1
|
+
"""Shared machinery of the httpx family: httpx and its successor httpx2.
|
|
2
|
+
|
|
3
|
+
SDK-agnostic BY CONSTRUCTION: this module never imports httpx or httpx2 (and
|
|
4
|
+
stays importable without either extra). Each adapter's modules hand their SDK
|
|
5
|
+
namespace in and bind the SDK base classes (byte streams, transports, error
|
|
6
|
+
families) to the logic mixins defined here. This is the ONE sanctioned channel
|
|
7
|
+
for code sharing between the paired adapters; the import-linter independence
|
|
8
|
+
contract still forbids importing a sibling adapter directly.
|
|
9
|
+
|
|
10
|
+
Public names in both adapters are IDENTICAL on purpose: migrating a service is
|
|
11
|
+
one extra and one import path.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ssl
|
|
17
|
+
from collections.abc import Callable, Mapping, MutableMapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..core.capabilities import (
|
|
21
|
+
AdapterCapabilities,
|
|
22
|
+
Capability,
|
|
23
|
+
DurationBoundary,
|
|
24
|
+
SeamGranularity,
|
|
25
|
+
Support,
|
|
26
|
+
)
|
|
27
|
+
from ..core.config import ClientConfig, RedirectMode, is_set, resolve
|
|
28
|
+
from ..core.contracts.adapter import AdapterDeps
|
|
29
|
+
from ..core.engine.aio import AsyncAttemptEngine
|
|
30
|
+
from ..core.engine.sync import SyncAttemptEngine
|
|
31
|
+
from ..core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
|
|
32
|
+
from ..core.model import IDEMPOTENT_METHODS, ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
|
|
33
|
+
from ..core.native import validate_native
|
|
34
|
+
from ..core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
|
|
35
|
+
from ..core.policy.timeout import base_timeouts
|
|
36
|
+
from ..core.telemetry.emitter import ClientTelemetry
|
|
37
|
+
|
|
38
|
+
# Per-call extension keys understood by clientwright (identical across the family).
|
|
39
|
+
ROUTE_EXTENSION = "clientwright_route"
|
|
40
|
+
IDEMPOTENT_EXTENSION = "clientwright_idempotent"
|
|
41
|
+
|
|
42
|
+
# The family defaults every phase to 5 seconds (DEFAULT_TIMEOUT_CONFIG).
|
|
43
|
+
NATIVE_TIMEOUT_DEFAULTS = ResolvedTimeouts(connect=5.0, read=5.0, write=5.0, pool_acquire=5.0)
|
|
44
|
+
|
|
45
|
+
BODY_HEADERS = ("content-length", "content-type", "transfer-encoding")
|
|
46
|
+
|
|
47
|
+
RESERVED_CLIENT_KEYS: Mapping[str, str] = {
|
|
48
|
+
"transport": "the transport belongs to clientwright; tune it via config or the 'transport' slot",
|
|
49
|
+
"mounts": "mounts are built by clientwright for proxy routing",
|
|
50
|
+
"timeout": "use ClientConfig.timeout",
|
|
51
|
+
"follow_redirects": "redirects are owned by the engine; use ClientConfig.redirects",
|
|
52
|
+
"max_redirects": "use ClientConfig.max_redirects",
|
|
53
|
+
"limits": "use ClientConfig.pool",
|
|
54
|
+
"http2": "use ClientConfig.pool.http2",
|
|
55
|
+
"verify": "use ClientConfig.tls",
|
|
56
|
+
"cert": "use ClientConfig.tls.cert",
|
|
57
|
+
"proxy": "use ClientConfig.proxy",
|
|
58
|
+
"proxies": "use ClientConfig.proxy",
|
|
59
|
+
"base_url": "use ClientConfig.base_url",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
RESERVED_TRANSPORT_KEYS: Mapping[str, str] = {
|
|
63
|
+
"verify": "use ClientConfig.tls",
|
|
64
|
+
"cert": "use ClientConfig.tls.cert",
|
|
65
|
+
"http2": "use ClientConfig.pool.http2",
|
|
66
|
+
"limits": "use ClientConfig.pool",
|
|
67
|
+
"proxy": "use ClientConfig.proxy",
|
|
68
|
+
"retries": "retries belong to clientwright; use ClientConfig.retry",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def capabilities_for(adapter: str) -> AdapterCapabilities:
|
|
73
|
+
"""The family capability record; only the adapter name differs."""
|
|
74
|
+
return AdapterCapabilities(
|
|
75
|
+
adapter=adapter,
|
|
76
|
+
seam="transport",
|
|
77
|
+
granularity=SeamGranularity.HOP,
|
|
78
|
+
boundary=DurationBoundary.HEADERS,
|
|
79
|
+
support={
|
|
80
|
+
Capability.TIMEOUT_TOTAL: Support.EMULATED,
|
|
81
|
+
Capability.TIMEOUT_ATTEMPT: Support.EMULATED,
|
|
82
|
+
Capability.TIMEOUT_CONNECT: Support.NATIVE,
|
|
83
|
+
Capability.TIMEOUT_READ: Support.NATIVE,
|
|
84
|
+
Capability.TIMEOUT_WRITE: Support.NATIVE,
|
|
85
|
+
Capability.TIMEOUT_POOL: Support.NATIVE,
|
|
86
|
+
Capability.DEADLINE_HARD: Support.EMULATED,
|
|
87
|
+
Capability.POOL_LIMIT_TOTAL: Support.NATIVE,
|
|
88
|
+
Capability.POOL_LIMIT_PER_HOST: Support.EMULATED,
|
|
89
|
+
Capability.KEEPALIVE: Support.NATIVE,
|
|
90
|
+
Capability.POOL_METRICS: Support.ABSENT,
|
|
91
|
+
Capability.CONN_METRICS: Support.ABSENT,
|
|
92
|
+
Capability.REDIRECTS_OWNABLE: Support.NATIVE,
|
|
93
|
+
Capability.NATIVE_RETRY_DISABLEABLE: Support.NATIVE,
|
|
94
|
+
Capability.PER_CALL_OPTIONS: Support.NATIVE,
|
|
95
|
+
Capability.RETROFIT: Support.ABSENT,
|
|
96
|
+
Capability.EXACT_NATIVE_TYPE: Support.NATIVE,
|
|
97
|
+
Capability.BALANCER: Support.ABSENT,
|
|
98
|
+
Capability.HTTP2: Support.NATIVE,
|
|
99
|
+
Capability.HTTP3: Support.ABSENT,
|
|
100
|
+
Capability.PROXY: Support.NATIVE,
|
|
101
|
+
},
|
|
102
|
+
emits=frozenset(
|
|
103
|
+
{
|
|
104
|
+
FailureKind.CONNECT_TIMEOUT,
|
|
105
|
+
FailureKind.READ_TIMEOUT,
|
|
106
|
+
FailureKind.WRITE_TIMEOUT,
|
|
107
|
+
FailureKind.POOL_TIMEOUT,
|
|
108
|
+
FailureKind.TOTAL_TIMEOUT,
|
|
109
|
+
FailureKind.CONNECT_ERROR,
|
|
110
|
+
FailureKind.TLS_ERROR,
|
|
111
|
+
FailureKind.PROTOCOL_ERROR,
|
|
112
|
+
FailureKind.DISCONNECTED,
|
|
113
|
+
FailureKind.BODY_ERROR,
|
|
114
|
+
FailureKind.STATUS,
|
|
115
|
+
FailureKind.CANCELLED,
|
|
116
|
+
FailureKind.CIRCUIT_OPEN,
|
|
117
|
+
FailureKind.UNKNOWN,
|
|
118
|
+
}
|
|
119
|
+
),
|
|
120
|
+
collapses={FailureKind.DNS_ERROR: FailureKind.CONNECT_ERROR},
|
|
121
|
+
notes={
|
|
122
|
+
"deadline_hard": "Hard cancellation on the async client only; the sync client clamps phases (soft).",
|
|
123
|
+
"pool_limit_per_host": "Emulated as a per-origin in-flight semaphore; limits requests, not connections.",
|
|
124
|
+
"dns_error": f"{adapter} wraps DNS failures into ConnectError; they surface as connect_error.",
|
|
125
|
+
"proxy_from_env": "Environment proxies are parsed into mounts; NO_PROXY entries match hosts literally.",
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def has_ssl_cause(exc: BaseException) -> bool:
|
|
131
|
+
seen: set[int] = set()
|
|
132
|
+
current: BaseException | None = exc
|
|
133
|
+
while current is not None and id(current) not in seen:
|
|
134
|
+
seen.add(id(current))
|
|
135
|
+
if isinstance(current, (ssl.SSLError, ssl.CertificateError)):
|
|
136
|
+
return True
|
|
137
|
+
current = current.__cause__ or current.__context__
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def classify_family_error(sdk: Any, exc: BaseException) -> FailureKind:
|
|
142
|
+
"""Exception -> FailureKind for any SDK exposing the httpx error names."""
|
|
143
|
+
import asyncio # noqa: PLC0415 - stdlib, deferred to keep module import light
|
|
144
|
+
|
|
145
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
146
|
+
return FailureKind.CANCELLED
|
|
147
|
+
if isinstance(exc, sdk.ConnectTimeout):
|
|
148
|
+
return FailureKind.CONNECT_TIMEOUT
|
|
149
|
+
if isinstance(exc, sdk.ReadTimeout):
|
|
150
|
+
return FailureKind.READ_TIMEOUT
|
|
151
|
+
if isinstance(exc, sdk.WriteTimeout):
|
|
152
|
+
return FailureKind.WRITE_TIMEOUT
|
|
153
|
+
if isinstance(exc, sdk.PoolTimeout):
|
|
154
|
+
return FailureKind.POOL_TIMEOUT
|
|
155
|
+
if isinstance(exc, sdk.ConnectError):
|
|
156
|
+
return FailureKind.TLS_ERROR if has_ssl_cause(exc) else FailureKind.CONNECT_ERROR
|
|
157
|
+
if isinstance(exc, (sdk.ReadError, sdk.WriteError, sdk.CloseError)):
|
|
158
|
+
return FailureKind.DISCONNECTED
|
|
159
|
+
if isinstance(exc, sdk.RemoteProtocolError):
|
|
160
|
+
# A premature server close arrives as RemoteProtocolError; that is a
|
|
161
|
+
# disconnect (retryable), not a malformed response.
|
|
162
|
+
return FailureKind.DISCONNECTED if "disconnect" in str(exc).lower() else FailureKind.PROTOCOL_ERROR
|
|
163
|
+
if isinstance(exc, sdk.LocalProtocolError):
|
|
164
|
+
return FailureKind.PROTOCOL_ERROR
|
|
165
|
+
if isinstance(exc, sdk.ProxyError):
|
|
166
|
+
return FailureKind.CONNECT_ERROR
|
|
167
|
+
if isinstance(exc, TimeoutError):
|
|
168
|
+
return FailureKind.TOTAL_TIMEOUT
|
|
169
|
+
return FailureKind.UNKNOWN
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def make_error_translator(
|
|
173
|
+
circuit_cls: type[CircuitOpenError],
|
|
174
|
+
deadline_cls: type[DeadlineExceededError],
|
|
175
|
+
redirects_cls: type[TooManyRedirectsError],
|
|
176
|
+
) -> Callable[[CallError], BaseException]:
|
|
177
|
+
"""Build the CallError -> dual-family translator from the bound classes."""
|
|
178
|
+
|
|
179
|
+
def translate(error: CallError) -> BaseException:
|
|
180
|
+
if isinstance(error, CircuitOpenError):
|
|
181
|
+
return circuit_cls(error.key, error.retry_after)
|
|
182
|
+
if isinstance(error, DeadlineExceededError):
|
|
183
|
+
return deadline_cls(error.total)
|
|
184
|
+
if isinstance(error, TooManyRedirectsError):
|
|
185
|
+
return redirects_cls(error.hops)
|
|
186
|
+
return error
|
|
187
|
+
|
|
188
|
+
return translate
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ssl_arguments(config: ClientConfig) -> dict[str, Any]:
|
|
192
|
+
"""TLS kwargs for the transport constructor.
|
|
193
|
+
|
|
194
|
+
``create_ssl_context`` returns early for ``verify=<path>`` and
|
|
195
|
+
``verify=False``, silently DROPPING the ``cert`` kwarg - so whenever a
|
|
196
|
+
client certificate meets either of those verify modes, the SSLContext is
|
|
197
|
+
baked here (which also surfaces a bad cert path at build time).
|
|
198
|
+
"""
|
|
199
|
+
tls = config.tls
|
|
200
|
+
if tls.cert is not None and (tls.ca_bundle is not None or not tls.verify):
|
|
201
|
+
context = ssl.create_default_context(cafile=tls.ca_bundle)
|
|
202
|
+
if not tls.verify and tls.ca_bundle is None:
|
|
203
|
+
context.check_hostname = False
|
|
204
|
+
context.verify_mode = ssl.CERT_NONE
|
|
205
|
+
if isinstance(tls.cert, str):
|
|
206
|
+
context.load_cert_chain(tls.cert)
|
|
207
|
+
else:
|
|
208
|
+
context.load_cert_chain(*tls.cert)
|
|
209
|
+
return {"verify": context}
|
|
210
|
+
kwargs: dict[str, Any] = {"verify": tls.ca_bundle if tls.ca_bundle is not None else tls.verify}
|
|
211
|
+
if tls.cert is not None:
|
|
212
|
+
kwargs["cert"] = tls.cert
|
|
213
|
+
return kwargs
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def timeout_dict(base: ResolvedTimeouts) -> dict[str, float | None]:
|
|
217
|
+
return {"connect": base.connect, "read": base.read, "write": base.write, "pool": base.pool_acquire}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def no_proxy_hosts(proxies: dict[str, str]) -> list[str]:
|
|
221
|
+
raw = proxies.get("no", "")
|
|
222
|
+
return [entry.strip() for entry in raw.split(",") if entry.strip() and entry.strip() != "*"]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def host_bypasses_proxy(host: str, no_proxy: tuple[str, ...]) -> bool:
|
|
226
|
+
for entry in no_proxy:
|
|
227
|
+
candidate = entry.lstrip(".")
|
|
228
|
+
if host == candidate or host.endswith("." + candidate):
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class FamilyRequestView:
|
|
234
|
+
"""Mutable view over a family Request; SDK handed in by the adapter."""
|
|
235
|
+
|
|
236
|
+
__slots__ = ("_caller", "_replay_stream", "_request", "_sdk")
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
request: Any,
|
|
241
|
+
default_timeout: dict[str, float | None],
|
|
242
|
+
*,
|
|
243
|
+
sdk: Any,
|
|
244
|
+
replay_stream: Callable[[bytes], Any],
|
|
245
|
+
) -> None:
|
|
246
|
+
self._request = request
|
|
247
|
+
self._sdk = sdk
|
|
248
|
+
self._replay_stream = replay_stream
|
|
249
|
+
# Detected ONCE, before the engine's own apply_timeouts mutates the
|
|
250
|
+
# extension - otherwise every redirect hop would misread the engine's
|
|
251
|
+
# previously planned values as a caller override.
|
|
252
|
+
self._caller = self._detect_caller(request, default_timeout)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _detect_caller(request: Any, default_timeout: dict[str, float | None]) -> ResolvedTimeouts | None:
|
|
256
|
+
current = request.extensions.get("timeout")
|
|
257
|
+
if not isinstance(current, dict) or dict(current) == default_timeout:
|
|
258
|
+
return None
|
|
259
|
+
return ResolvedTimeouts(
|
|
260
|
+
connect=current.get("connect"),
|
|
261
|
+
read=current.get("read"),
|
|
262
|
+
write=current.get("write"),
|
|
263
|
+
pool_acquire=current.get("pool"),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def native(self) -> Any:
|
|
268
|
+
return self._request
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def info(self) -> RequestInfo:
|
|
272
|
+
request = self._request
|
|
273
|
+
url = str(request.url)
|
|
274
|
+
method = request.method
|
|
275
|
+
route = request.extensions.get(ROUTE_EXTENSION)
|
|
276
|
+
idempotent = bool(request.extensions.get(IDEMPOTENT_EXTENSION, method in IDEMPOTENT_METHODS))
|
|
277
|
+
return RequestInfo(
|
|
278
|
+
method=method,
|
|
279
|
+
origin=origin_of(url),
|
|
280
|
+
url=url,
|
|
281
|
+
route=route if isinstance(route, str) else None,
|
|
282
|
+
idempotent=idempotent,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def headers(self) -> MutableMapping[str, str]:
|
|
287
|
+
return self._request.headers # type: ignore[no-any-return]
|
|
288
|
+
|
|
289
|
+
def caller_timeouts(self) -> ResolvedTimeouts | None:
|
|
290
|
+
return self._caller
|
|
291
|
+
|
|
292
|
+
def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
|
|
293
|
+
self._request.extensions["timeout"] = {
|
|
294
|
+
"connect": timeouts.connect,
|
|
295
|
+
"read": timeouts.read,
|
|
296
|
+
"write": timeouts.write,
|
|
297
|
+
"pool": timeouts.pool_acquire,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
|
|
301
|
+
"""Mutate the ORIGINAL request in place.
|
|
302
|
+
|
|
303
|
+
Identity matters: the client layer holds this very object and later
|
|
304
|
+
does ``response.request = request`` and cookie extraction against its
|
|
305
|
+
URL - a replacement object would leave the final response attributed to
|
|
306
|
+
the pre-redirect URL (cross-origin Set-Cookie would land in the jar
|
|
307
|
+
under the wrong host).
|
|
308
|
+
"""
|
|
309
|
+
request = self._request
|
|
310
|
+
target = self._sdk.URL(url)
|
|
311
|
+
request.url = target
|
|
312
|
+
request.headers["host"] = target.netloc.decode("ascii")
|
|
313
|
+
if method is not None:
|
|
314
|
+
request.method = method
|
|
315
|
+
if drop_body:
|
|
316
|
+
for name in BODY_HEADERS:
|
|
317
|
+
request.headers.pop(name, None)
|
|
318
|
+
request.stream = self._replay_stream(b"")
|
|
319
|
+
# Keep .read()/.content consistent with the emptied stream.
|
|
320
|
+
request._content = b""
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class FamilyResponseView:
|
|
324
|
+
__slots__ = ("_response",)
|
|
325
|
+
|
|
326
|
+
def __init__(self, response: Any) -> None:
|
|
327
|
+
self._response = response
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def native(self) -> Any:
|
|
331
|
+
return self._response
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def status_code(self) -> int:
|
|
335
|
+
return self._response.status_code # type: ignore[no-any-return]
|
|
336
|
+
|
|
337
|
+
def header(self, name: str) -> str | None:
|
|
338
|
+
value = self._response.headers.get(name)
|
|
339
|
+
return value if isinstance(value, str) else None
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def location(self) -> str | None:
|
|
343
|
+
return self.header("location")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class TimedStreamCore:
|
|
347
|
+
"""Times body consumption and reports read failures exactly once."""
|
|
348
|
+
|
|
349
|
+
def __init__(self, inner: Any, clock: Callable[[], float], on_done: Callable[[Outcome, float], None]) -> None:
|
|
350
|
+
self._inner = inner
|
|
351
|
+
self._clock = clock
|
|
352
|
+
self._on_done = on_done
|
|
353
|
+
self._started = clock()
|
|
354
|
+
self._finished = False
|
|
355
|
+
|
|
356
|
+
def _finish(self, outcome: Outcome) -> None:
|
|
357
|
+
if not self._finished:
|
|
358
|
+
self._finished = True
|
|
359
|
+
self._on_done(outcome, self._clock() - self._started)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class AsyncTimedStreamMixin(TimedStreamCore):
|
|
363
|
+
async def __aiter__(self) -> Any:
|
|
364
|
+
try:
|
|
365
|
+
async for chunk in self._inner:
|
|
366
|
+
yield chunk
|
|
367
|
+
except Exception as exc:
|
|
368
|
+
self._finish(Outcome(kind=FailureKind.BODY_ERROR, exception=exc))
|
|
369
|
+
raise
|
|
370
|
+
self._finish(Outcome(kind=None))
|
|
371
|
+
|
|
372
|
+
async def aclose(self) -> None:
|
|
373
|
+
try:
|
|
374
|
+
await self._inner.aclose()
|
|
375
|
+
finally:
|
|
376
|
+
self._finish(Outcome(kind=None))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class SyncTimedStreamMixin(TimedStreamCore):
|
|
380
|
+
def __iter__(self) -> Any:
|
|
381
|
+
try:
|
|
382
|
+
yield from self._inner
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
self._finish(Outcome(kind=FailureKind.BODY_ERROR, exception=exc))
|
|
385
|
+
raise
|
|
386
|
+
self._finish(Outcome(kind=None))
|
|
387
|
+
|
|
388
|
+
def close(self) -> None:
|
|
389
|
+
try:
|
|
390
|
+
self._inner.close()
|
|
391
|
+
finally:
|
|
392
|
+
self._finish(Outcome(kind=None))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class AsyncFamilyNormalizer:
|
|
396
|
+
"""Async normalizer template; subclasses bind SDK-specific stream classes."""
|
|
397
|
+
|
|
398
|
+
def __init__(
|
|
399
|
+
self,
|
|
400
|
+
default_timeout: dict[str, float | None],
|
|
401
|
+
clock: Callable[[], float],
|
|
402
|
+
*,
|
|
403
|
+
sdk: Any,
|
|
404
|
+
request_view: Callable[[Any], Any],
|
|
405
|
+
timed_stream: Callable[..., Any],
|
|
406
|
+
) -> None:
|
|
407
|
+
self._default_timeout = default_timeout
|
|
408
|
+
self._clock = clock
|
|
409
|
+
self._sdk = sdk
|
|
410
|
+
self._request_view = request_view
|
|
411
|
+
self._timed_stream = timed_stream
|
|
412
|
+
|
|
413
|
+
def wrap_request(self, native: Any) -> Any:
|
|
414
|
+
return self._request_view(native)
|
|
415
|
+
|
|
416
|
+
def wrap_response(self, native: Any) -> FamilyResponseView:
|
|
417
|
+
return FamilyResponseView(native)
|
|
418
|
+
|
|
419
|
+
def classify_error(self, exc: BaseException) -> FailureKind:
|
|
420
|
+
return classify_family_error(self._sdk, exc)
|
|
421
|
+
|
|
422
|
+
def classify_response(self, response: Any) -> Outcome:
|
|
423
|
+
from ..core.engine.base import default_response_outcome # noqa: PLC0415 - avoid import cycle at module load
|
|
424
|
+
|
|
425
|
+
return default_response_outcome(response)
|
|
426
|
+
|
|
427
|
+
async def freeze(self, request: Any) -> bool:
|
|
428
|
+
try:
|
|
429
|
+
await request.native.aread()
|
|
430
|
+
except Exception:
|
|
431
|
+
return False
|
|
432
|
+
return True
|
|
433
|
+
|
|
434
|
+
async def rewind(self, request: Any) -> None:
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
async def discard(self, response: Any) -> None:
|
|
438
|
+
try:
|
|
439
|
+
await response.native.aclose()
|
|
440
|
+
except Exception:
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
def wrap_stream(self, response: Any, on_done: Callable[[Outcome, float], None]) -> None:
|
|
444
|
+
native = response.native
|
|
445
|
+
stream = native.stream
|
|
446
|
+
if isinstance(stream, self._sdk.AsyncByteStream):
|
|
447
|
+
native.stream = self._timed_stream(stream, self._clock, on_done)
|
|
448
|
+
|
|
449
|
+
def conn_metrics(self, response: Any) -> ConnMetrics | None:
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class SyncFamilyNormalizer:
|
|
454
|
+
"""Sync twin; blocking body operations."""
|
|
455
|
+
|
|
456
|
+
def __init__(
|
|
457
|
+
self,
|
|
458
|
+
default_timeout: dict[str, float | None],
|
|
459
|
+
clock: Callable[[], float],
|
|
460
|
+
*,
|
|
461
|
+
sdk: Any,
|
|
462
|
+
request_view: Callable[[Any], Any],
|
|
463
|
+
timed_stream: Callable[..., Any],
|
|
464
|
+
) -> None:
|
|
465
|
+
self._default_timeout = default_timeout
|
|
466
|
+
self._clock = clock
|
|
467
|
+
self._sdk = sdk
|
|
468
|
+
self._request_view = request_view
|
|
469
|
+
self._timed_stream = timed_stream
|
|
470
|
+
|
|
471
|
+
def wrap_request(self, native: Any) -> Any:
|
|
472
|
+
return self._request_view(native)
|
|
473
|
+
|
|
474
|
+
def wrap_response(self, native: Any) -> FamilyResponseView:
|
|
475
|
+
return FamilyResponseView(native)
|
|
476
|
+
|
|
477
|
+
def classify_error(self, exc: BaseException) -> FailureKind:
|
|
478
|
+
return classify_family_error(self._sdk, exc)
|
|
479
|
+
|
|
480
|
+
def classify_response(self, response: Any) -> Outcome:
|
|
481
|
+
from ..core.engine.base import default_response_outcome # noqa: PLC0415 - avoid import cycle at module load
|
|
482
|
+
|
|
483
|
+
return default_response_outcome(response)
|
|
484
|
+
|
|
485
|
+
def freeze(self, request: Any) -> bool:
|
|
486
|
+
try:
|
|
487
|
+
request.native.read()
|
|
488
|
+
except Exception:
|
|
489
|
+
return False
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
def rewind(self, request: Any) -> None:
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
def discard(self, response: Any) -> None:
|
|
496
|
+
try:
|
|
497
|
+
response.native.close()
|
|
498
|
+
except Exception:
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
def wrap_stream(self, response: Any, on_done: Callable[[Outcome, float], None]) -> None:
|
|
502
|
+
native = response.native
|
|
503
|
+
stream = native.stream
|
|
504
|
+
if isinstance(stream, self._sdk.SyncByteStream):
|
|
505
|
+
native.stream = self._timed_stream(stream, self._clock, on_done)
|
|
506
|
+
|
|
507
|
+
def conn_metrics(self, response: Any) -> ConnMetrics | None:
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class AsyncEngineTransportMixin:
|
|
512
|
+
"""Bound by adapters as ``class T(AsyncEngineTransportMixin, sdk.AsyncBaseTransport)``."""
|
|
513
|
+
|
|
514
|
+
def __init__(self, inner: Any, engine: AsyncAttemptEngine) -> None:
|
|
515
|
+
self._inner = inner
|
|
516
|
+
self._engine = engine
|
|
517
|
+
|
|
518
|
+
async def _send(self, request: Any) -> Any:
|
|
519
|
+
return await self._inner.handle_async_request(request.native)
|
|
520
|
+
|
|
521
|
+
async def handle_async_request(self, request: Any) -> Any:
|
|
522
|
+
return await self._engine.run(request, self._send)
|
|
523
|
+
|
|
524
|
+
async def aclose(self) -> None:
|
|
525
|
+
await self._inner.aclose()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class SyncEngineTransportMixin:
|
|
529
|
+
def __init__(self, inner: Any, engine: SyncAttemptEngine) -> None:
|
|
530
|
+
self._inner = inner
|
|
531
|
+
self._engine = engine
|
|
532
|
+
|
|
533
|
+
def _send(self, request: Any) -> Any:
|
|
534
|
+
return self._inner.handle_request(request.native)
|
|
535
|
+
|
|
536
|
+
def handle_request(self, request: Any) -> Any:
|
|
537
|
+
return self._engine.run(request, self._send)
|
|
538
|
+
|
|
539
|
+
def close(self) -> None:
|
|
540
|
+
self._inner.close()
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class AsyncProxyRouterMixin:
|
|
544
|
+
"""Routes every request - including owned-redirect hops - to the right proxy.
|
|
545
|
+
|
|
546
|
+
Client-level mounts are resolved by the client ONCE per logical call,
|
|
547
|
+
before the engine's redirect loop; this router sits INSIDE the engine seam
|
|
548
|
+
instead, so a hop that crosses schemes or lands on a NO_PROXY host
|
|
549
|
+
re-routes correctly.
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
def __init__(self, direct: Any, by_scheme: dict[str, Any], no_proxy_hosts: tuple[str, ...]) -> None:
|
|
553
|
+
self._direct = direct
|
|
554
|
+
self._by_scheme = by_scheme
|
|
555
|
+
self._no_proxy_hosts = no_proxy_hosts
|
|
556
|
+
|
|
557
|
+
def _pick(self, url: Any) -> Any:
|
|
558
|
+
if host_bypasses_proxy(url.host, self._no_proxy_hosts):
|
|
559
|
+
return self._direct
|
|
560
|
+
return self._by_scheme.get(url.scheme, self._direct)
|
|
561
|
+
|
|
562
|
+
async def handle_async_request(self, request: Any) -> Any:
|
|
563
|
+
return await self._pick(request.url).handle_async_request(request)
|
|
564
|
+
|
|
565
|
+
async def aclose(self) -> None:
|
|
566
|
+
await self._direct.aclose()
|
|
567
|
+
for transport in self._by_scheme.values():
|
|
568
|
+
await transport.aclose()
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class SyncProxyRouterMixin:
|
|
572
|
+
def __init__(self, direct: Any, by_scheme: dict[str, Any], no_proxy_hosts: tuple[str, ...]) -> None:
|
|
573
|
+
self._direct = direct
|
|
574
|
+
self._by_scheme = by_scheme
|
|
575
|
+
self._no_proxy_hosts = no_proxy_hosts
|
|
576
|
+
|
|
577
|
+
def _pick(self, url: Any) -> Any:
|
|
578
|
+
if host_bypasses_proxy(url.host, self._no_proxy_hosts):
|
|
579
|
+
return self._direct
|
|
580
|
+
return self._by_scheme.get(url.scheme, self._direct)
|
|
581
|
+
|
|
582
|
+
def handle_request(self, request: Any) -> Any:
|
|
583
|
+
return self._pick(request.url).handle_request(request)
|
|
584
|
+
|
|
585
|
+
def close(self) -> None:
|
|
586
|
+
self._direct.close()
|
|
587
|
+
for transport in self._by_scheme.values():
|
|
588
|
+
transport.close()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class FamilyAdapter:
|
|
592
|
+
"""The whole builder, shared; subclasses bind SDK and SDK-based classes."""
|
|
593
|
+
|
|
594
|
+
# Bound by each adapter:
|
|
595
|
+
name: str
|
|
596
|
+
capabilities: AdapterCapabilities
|
|
597
|
+
sdk: Any
|
|
598
|
+
async_normalizer: Callable[[dict[str, float | None], Callable[[], float]], Any]
|
|
599
|
+
sync_normalizer: Callable[[dict[str, float | None], Callable[[], float]], Any]
|
|
600
|
+
async_engine_transport: Callable[[Any, AsyncAttemptEngine], Any]
|
|
601
|
+
sync_engine_transport: Callable[[Any, SyncAttemptEngine], Any]
|
|
602
|
+
async_proxy_router: Callable[[Any, dict[str, Any], tuple[str, ...]], Any]
|
|
603
|
+
sync_proxy_router: Callable[[Any, dict[str, Any], tuple[str, ...]], Any]
|
|
604
|
+
translate: Callable[[CallError], BaseException]
|
|
605
|
+
|
|
606
|
+
native_slots = frozenset({"client", "transport"})
|
|
607
|
+
reserved_keys: Mapping[str, Mapping[str, str]] = {
|
|
608
|
+
"client": RESERVED_CLIENT_KEYS,
|
|
609
|
+
"transport": RESERVED_TRANSPORT_KEYS,
|
|
610
|
+
}
|
|
611
|
+
allowed_keys: Mapping[str, frozenset[str] | None] = {"client": None, "transport": None}
|
|
612
|
+
|
|
613
|
+
def _validated_native(self, config: ClientConfig, *, sync: bool) -> dict[str, dict[str, Any]]:
|
|
614
|
+
client_target = self.sdk.Client if sync else self.sdk.AsyncClient
|
|
615
|
+
transport_target = self.sdk.HTTPTransport if sync else self.sdk.AsyncHTTPTransport
|
|
616
|
+
return validate_native(
|
|
617
|
+
config.native,
|
|
618
|
+
slots=self.native_slots,
|
|
619
|
+
reserved=self.reserved_keys,
|
|
620
|
+
allowed=self.allowed_keys,
|
|
621
|
+
signature_targets={"client": client_target, "transport": transport_target},
|
|
622
|
+
config_conflicts={},
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def _compile(self, config: ClientConfig, *, sync: bool) -> CallPlan:
|
|
626
|
+
applied = {
|
|
627
|
+
Capability.TIMEOUT_CONNECT,
|
|
628
|
+
Capability.TIMEOUT_READ,
|
|
629
|
+
Capability.TIMEOUT_WRITE,
|
|
630
|
+
Capability.TIMEOUT_POOL,
|
|
631
|
+
Capability.POOL_LIMIT_TOTAL,
|
|
632
|
+
Capability.KEEPALIVE,
|
|
633
|
+
Capability.REDIRECTS_OWNABLE,
|
|
634
|
+
}
|
|
635
|
+
if resolve(config.pool.http2, False):
|
|
636
|
+
applied.add(Capability.HTTP2)
|
|
637
|
+
if config.proxy is not None:
|
|
638
|
+
applied.add(Capability.PROXY)
|
|
639
|
+
emulated = {Capability.TIMEOUT_TOTAL}
|
|
640
|
+
dropped: dict[Capability, str] = {}
|
|
641
|
+
if sync:
|
|
642
|
+
# The sync engine cannot cancel a blocked socket call: no hard
|
|
643
|
+
# deadline, no per-attempt ceiling. Saying so in the report is the
|
|
644
|
+
# whole point of the report.
|
|
645
|
+
if is_set(config.timeout.attempt) and resolve(config.timeout.attempt, None) is not None:
|
|
646
|
+
dropped[Capability.TIMEOUT_ATTEMPT] = (
|
|
647
|
+
"sync engine cannot cancel a blocked attempt; only phase timeouts and the soft total apply"
|
|
648
|
+
)
|
|
649
|
+
else:
|
|
650
|
+
emulated |= {Capability.TIMEOUT_ATTEMPT, Capability.DEADLINE_HARD}
|
|
651
|
+
if resolve(config.pool.max_connections_per_host, None) is not None:
|
|
652
|
+
emulated.add(Capability.POOL_LIMIT_PER_HOST)
|
|
653
|
+
plan = compile_plan(
|
|
654
|
+
config,
|
|
655
|
+
self.capabilities,
|
|
656
|
+
native_timeout_defaults=NATIVE_TIMEOUT_DEFAULTS,
|
|
657
|
+
applied_natively=frozenset(applied),
|
|
658
|
+
emulated=frozenset(emulated),
|
|
659
|
+
dropped=dropped,
|
|
660
|
+
)
|
|
661
|
+
plan.report.enforce(config.on_unsupported)
|
|
662
|
+
return plan
|
|
663
|
+
|
|
664
|
+
def _limits(self, config: ClientConfig) -> Any:
|
|
665
|
+
return self.sdk.Limits(
|
|
666
|
+
max_connections=resolve(config.pool.max_connections, 100),
|
|
667
|
+
max_keepalive_connections=resolve(config.pool.max_keepalive, 20),
|
|
668
|
+
keepalive_expiry=resolve(config.pool.keepalive_expiry, 5.0),
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def _client_timeout(self, base: ResolvedTimeouts) -> Any:
|
|
672
|
+
return self.sdk.Timeout(connect=base.connect, read=base.read, write=base.write, pool=base.pool_acquire)
|
|
673
|
+
|
|
674
|
+
def _transport_kwargs(self, config: ClientConfig, native_transport: dict[str, Any]) -> dict[str, Any]:
|
|
675
|
+
kwargs: dict[str, Any] = {
|
|
676
|
+
"http2": resolve(config.pool.http2, False),
|
|
677
|
+
"limits": self._limits(config),
|
|
678
|
+
}
|
|
679
|
+
kwargs.update(ssl_arguments(config))
|
|
680
|
+
kwargs.update(native_transport)
|
|
681
|
+
return kwargs
|
|
682
|
+
|
|
683
|
+
def _telemetry(self, config: ClientConfig, deps: AdapterDeps) -> ClientTelemetry:
|
|
684
|
+
return ClientTelemetry(
|
|
685
|
+
service=config.service_name,
|
|
686
|
+
adapter=self.name,
|
|
687
|
+
seam=self.capabilities.seam,
|
|
688
|
+
config=config.observability,
|
|
689
|
+
metrics=deps.metrics,
|
|
690
|
+
tracer=deps.tracer,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
|
|
694
|
+
from urllib.request import getproxies # noqa: PLC0415 - stdlib, deferred for import cost
|
|
695
|
+
|
|
696
|
+
sdk = self.sdk
|
|
697
|
+
native = self._validated_native(config, sync=False)
|
|
698
|
+
telemetry = self._telemetry(config, deps)
|
|
699
|
+
runtime = deps.runtime or ClientRuntime.for_config(
|
|
700
|
+
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
701
|
+
)
|
|
702
|
+
plan = self._compile(config, sync=False)
|
|
703
|
+
base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
|
|
704
|
+
engine = AsyncAttemptEngine(
|
|
705
|
+
plan=plan,
|
|
706
|
+
runtime=runtime,
|
|
707
|
+
telemetry=telemetry,
|
|
708
|
+
normalizer=type(self).async_normalizer(timeout_dict(base), runtime.clock),
|
|
709
|
+
deps=deps,
|
|
710
|
+
translate=type(self).translate,
|
|
711
|
+
)
|
|
712
|
+
transport_kwargs = self._transport_kwargs(config, native.get("transport", {}))
|
|
713
|
+
|
|
714
|
+
def make_raw(proxy: str | None) -> Any:
|
|
715
|
+
return sdk.AsyncHTTPTransport(proxy=proxy, **transport_kwargs)
|
|
716
|
+
|
|
717
|
+
explicit_proxy = config.proxy.url if config.proxy is not None else None
|
|
718
|
+
inner: Any = make_raw(explicit_proxy)
|
|
719
|
+
if config.proxy is not None and config.proxy.from_env:
|
|
720
|
+
proxies = getproxies()
|
|
721
|
+
by_scheme: dict[str, Any] = {
|
|
722
|
+
scheme: make_raw(proxies[scheme]) for scheme in ("http", "https") if scheme in proxies
|
|
723
|
+
}
|
|
724
|
+
if by_scheme:
|
|
725
|
+
# The router sits INSIDE the engine seam, so every owned-redirect
|
|
726
|
+
# hop re-routes through the right proxy (client-level mounts are
|
|
727
|
+
# resolved only once per logical call).
|
|
728
|
+
inner = type(self).async_proxy_router(make_raw(None), by_scheme, tuple(no_proxy_hosts(proxies)))
|
|
729
|
+
transport = type(self).async_engine_transport(inner, engine)
|
|
730
|
+
client = sdk.AsyncClient(
|
|
731
|
+
base_url=config.base_url or "",
|
|
732
|
+
transport=transport,
|
|
733
|
+
follow_redirects=config.redirects is RedirectMode.NATIVE,
|
|
734
|
+
max_redirects=config.max_redirects,
|
|
735
|
+
timeout=self._client_timeout(base),
|
|
736
|
+
**native.get("client", {}),
|
|
737
|
+
)
|
|
738
|
+
handle: ClientHandle[Any] = ClientHandle(
|
|
739
|
+
client=client,
|
|
740
|
+
adapter=self.name,
|
|
741
|
+
capabilities=self.capabilities,
|
|
742
|
+
report=plan.report,
|
|
743
|
+
runtime=runtime,
|
|
744
|
+
plan=plan,
|
|
745
|
+
aclose=client.aclose,
|
|
746
|
+
)
|
|
747
|
+
register_handle(client, handle)
|
|
748
|
+
return handle
|
|
749
|
+
|
|
750
|
+
def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
|
|
751
|
+
from urllib.request import getproxies # noqa: PLC0415 - stdlib, deferred for import cost
|
|
752
|
+
|
|
753
|
+
sdk = self.sdk
|
|
754
|
+
native = self._validated_native(config, sync=True)
|
|
755
|
+
telemetry = self._telemetry(config, deps)
|
|
756
|
+
runtime = deps.runtime or ClientRuntime.for_config(
|
|
757
|
+
config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
758
|
+
)
|
|
759
|
+
plan = self._compile(config, sync=True)
|
|
760
|
+
base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
|
|
761
|
+
engine = SyncAttemptEngine(
|
|
762
|
+
plan=plan,
|
|
763
|
+
runtime=runtime,
|
|
764
|
+
telemetry=telemetry,
|
|
765
|
+
normalizer=type(self).sync_normalizer(timeout_dict(base), runtime.clock),
|
|
766
|
+
deps=deps,
|
|
767
|
+
translate=type(self).translate,
|
|
768
|
+
)
|
|
769
|
+
transport_kwargs = self._transport_kwargs(config, native.get("transport", {}))
|
|
770
|
+
|
|
771
|
+
def make_raw(proxy: str | None) -> Any:
|
|
772
|
+
return sdk.HTTPTransport(proxy=proxy, **transport_kwargs)
|
|
773
|
+
|
|
774
|
+
explicit_proxy = config.proxy.url if config.proxy is not None else None
|
|
775
|
+
inner: Any = make_raw(explicit_proxy)
|
|
776
|
+
if config.proxy is not None and config.proxy.from_env:
|
|
777
|
+
proxies = getproxies()
|
|
778
|
+
by_scheme: dict[str, Any] = {
|
|
779
|
+
scheme: make_raw(proxies[scheme]) for scheme in ("http", "https") if scheme in proxies
|
|
780
|
+
}
|
|
781
|
+
if by_scheme:
|
|
782
|
+
inner = type(self).sync_proxy_router(make_raw(None), by_scheme, tuple(no_proxy_hosts(proxies)))
|
|
783
|
+
transport = type(self).sync_engine_transport(inner, engine)
|
|
784
|
+
client = sdk.Client(
|
|
785
|
+
base_url=config.base_url or "",
|
|
786
|
+
transport=transport,
|
|
787
|
+
follow_redirects=config.redirects is RedirectMode.NATIVE,
|
|
788
|
+
max_redirects=config.max_redirects,
|
|
789
|
+
timeout=self._client_timeout(base),
|
|
790
|
+
**native.get("client", {}),
|
|
791
|
+
)
|
|
792
|
+
handle: ClientHandle[Any] = ClientHandle(
|
|
793
|
+
client=client,
|
|
794
|
+
adapter=self.name,
|
|
795
|
+
capabilities=self.capabilities,
|
|
796
|
+
report=plan.report,
|
|
797
|
+
runtime=runtime,
|
|
798
|
+
plan=plan,
|
|
799
|
+
close=client.close,
|
|
800
|
+
)
|
|
801
|
+
register_handle(client, handle)
|
|
802
|
+
return handle
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
__all__ = [
|
|
806
|
+
"BODY_HEADERS",
|
|
807
|
+
"IDEMPOTENT_EXTENSION",
|
|
808
|
+
"NATIVE_TIMEOUT_DEFAULTS",
|
|
809
|
+
"RESERVED_CLIENT_KEYS",
|
|
810
|
+
"RESERVED_TRANSPORT_KEYS",
|
|
811
|
+
"ROUTE_EXTENSION",
|
|
812
|
+
"AsyncEngineTransportMixin",
|
|
813
|
+
"AsyncFamilyNormalizer",
|
|
814
|
+
"AsyncProxyRouterMixin",
|
|
815
|
+
"AsyncTimedStreamMixin",
|
|
816
|
+
"FamilyAdapter",
|
|
817
|
+
"FamilyRequestView",
|
|
818
|
+
"FamilyResponseView",
|
|
819
|
+
"SyncEngineTransportMixin",
|
|
820
|
+
"SyncFamilyNormalizer",
|
|
821
|
+
"SyncProxyRouterMixin",
|
|
822
|
+
"SyncTimedStreamMixin",
|
|
823
|
+
"TimedStreamCore",
|
|
824
|
+
"capabilities_for",
|
|
825
|
+
"classify_family_error",
|
|
826
|
+
"host_bypasses_proxy",
|
|
827
|
+
"make_error_translator",
|
|
828
|
+
"no_proxy_hosts",
|
|
829
|
+
"ssl_arguments",
|
|
830
|
+
"timeout_dict",
|
|
831
|
+
]
|