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,241 @@
|
|
|
1
|
+
"""The single synchronous attempt loop; mirrors AsyncAttemptEngine.
|
|
2
|
+
|
|
3
|
+
Sync runtimes cannot cancel a blocked socket call, so the total deadline is
|
|
4
|
+
SOFT here: per-attempt phases are clamped by the remaining budget and the
|
|
5
|
+
deadline is re-checked at attempt boundaries. Adapters declare this as
|
|
6
|
+
``DEADLINE_HARD: ABSENT``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from contextlib import ExitStack
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..contracts.adapter import AdapterDeps
|
|
18
|
+
from ..contracts.message import RequestView, ResponseView, SyncNormalizer
|
|
19
|
+
from ..errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
|
|
20
|
+
from ..model import Attempt, FailureKind, Outcome
|
|
21
|
+
from ..plan import CallPlan, ClientRuntime
|
|
22
|
+
from ..policy.budget import Deadline
|
|
23
|
+
from ..telemetry.emitter import CallObservation, ClientTelemetry
|
|
24
|
+
from .base import SKIP_REASONS, deadline_header_value, inject_static_headers
|
|
25
|
+
from .redirects import apply_redirect, plan_redirect
|
|
26
|
+
|
|
27
|
+
type SyncSend = Callable[[RequestView], Any]
|
|
28
|
+
type ErrorTranslator = Callable[[CallError], BaseException]
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("clientwright.engine")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SyncAttemptEngine:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
plan: CallPlan,
|
|
38
|
+
runtime: ClientRuntime,
|
|
39
|
+
telemetry: ClientTelemetry,
|
|
40
|
+
normalizer: SyncNormalizer,
|
|
41
|
+
deps: AdapterDeps,
|
|
42
|
+
translate: ErrorTranslator,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._plan = plan
|
|
45
|
+
self._runtime = runtime
|
|
46
|
+
self._telemetry = telemetry
|
|
47
|
+
self._norm = normalizer
|
|
48
|
+
self._deps = deps
|
|
49
|
+
self._translate = translate
|
|
50
|
+
|
|
51
|
+
def run(self, native_request: Any, send: SyncSend) -> Any:
|
|
52
|
+
clock = self._runtime.clock
|
|
53
|
+
request = self._norm.wrap_request(native_request)
|
|
54
|
+
info = request.info
|
|
55
|
+
started = clock()
|
|
56
|
+
observation = self._telemetry.call_start(info, started)
|
|
57
|
+
final_outcome = Outcome(kind=FailureKind.UNKNOWN)
|
|
58
|
+
response: ResponseView | None = None
|
|
59
|
+
try:
|
|
60
|
+
response, final_outcome = self._admitted(request, send, observation)
|
|
61
|
+
except CallError as error:
|
|
62
|
+
final_outcome = self._call_error_outcome(error)
|
|
63
|
+
raise self._translate(error) from error
|
|
64
|
+
except BaseException as error:
|
|
65
|
+
if final_outcome.exception is not error:
|
|
66
|
+
final_outcome = Outcome(kind=self._norm.classify_error(error), exception=error)
|
|
67
|
+
raise
|
|
68
|
+
finally:
|
|
69
|
+
self._telemetry.call_end(observation, info, final_outcome, clock() - started)
|
|
70
|
+
if final_outcome.exception is not None:
|
|
71
|
+
raise final_outcome.exception
|
|
72
|
+
assert response is not None
|
|
73
|
+
self._wrap_stream(response, info)
|
|
74
|
+
return response.native
|
|
75
|
+
|
|
76
|
+
def _call_error_outcome(self, error: CallError) -> Outcome:
|
|
77
|
+
if isinstance(error, CircuitOpenError):
|
|
78
|
+
return Outcome(kind=FailureKind.CIRCUIT_OPEN, exception=error)
|
|
79
|
+
if isinstance(error, DeadlineExceededError):
|
|
80
|
+
return Outcome(kind=FailureKind.TOTAL_TIMEOUT, exception=error)
|
|
81
|
+
return Outcome(kind=FailureKind.UNKNOWN, exception=error)
|
|
82
|
+
|
|
83
|
+
def _wrap_stream(self, response: ResponseView, info: Any) -> None:
|
|
84
|
+
def on_done(outcome: Outcome, duration: float) -> None:
|
|
85
|
+
self._telemetry.record_body_duration(info, duration)
|
|
86
|
+
|
|
87
|
+
self._norm.wrap_stream(response, on_done)
|
|
88
|
+
|
|
89
|
+
def _inject_headers(self, request: RequestView) -> None:
|
|
90
|
+
headers = request.headers
|
|
91
|
+
inject_static_headers(headers, dict(self._plan.config.headers))
|
|
92
|
+
for provider in self._deps.header_providers:
|
|
93
|
+
try:
|
|
94
|
+
inject_static_headers(headers, dict(provider()))
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.debug("Header provider %r failed", provider, exc_info=True)
|
|
97
|
+
continue
|
|
98
|
+
self._telemetry.tracer.inject_context(headers)
|
|
99
|
+
|
|
100
|
+
def _deadline(self) -> Deadline:
|
|
101
|
+
ambient = self._deps.deadline_source.remaining() if self._deps.deadline_source is not None else None
|
|
102
|
+
return Deadline.intersect(self._runtime.clock, self._plan.config.timeout.total, ambient)
|
|
103
|
+
|
|
104
|
+
def _admitted(
|
|
105
|
+
self, request: RequestView, send: SyncSend, observation: CallObservation
|
|
106
|
+
) -> tuple[ResponseView | None, Outcome]:
|
|
107
|
+
plan = self._plan
|
|
108
|
+
runtime = self._runtime
|
|
109
|
+
info = request.info
|
|
110
|
+
deadline = self._deadline()
|
|
111
|
+
self._inject_headers(request)
|
|
112
|
+
circuit_key: str | None = None
|
|
113
|
+
with ExitStack() as stack:
|
|
114
|
+
if plan.use_origin_limiter and runtime.sync_limiter is not None:
|
|
115
|
+
stack.enter_context(runtime.sync_limiter.acquire(info.origin))
|
|
116
|
+
if runtime.circuits is not None and plan.config.circuit_breaker is not None:
|
|
117
|
+
circuit_key = info.circuit_key(plan.config.circuit_breaker.key)
|
|
118
|
+
runtime.circuits.check(circuit_key)
|
|
119
|
+
if runtime.retry_budgets is not None:
|
|
120
|
+
runtime.retry_budgets.earn(info.origin)
|
|
121
|
+
outcome = Outcome(kind=FailureKind.UNKNOWN)
|
|
122
|
+
aborted = False
|
|
123
|
+
try:
|
|
124
|
+
response, outcome = self._hops(request, send, deadline, observation)
|
|
125
|
+
except CallError as error:
|
|
126
|
+
outcome = self._call_error_outcome(error)
|
|
127
|
+
raise
|
|
128
|
+
except BaseException:
|
|
129
|
+
# No classified outcome: neither success nor failure for the breaker.
|
|
130
|
+
aborted = True
|
|
131
|
+
raise
|
|
132
|
+
else:
|
|
133
|
+
return response, outcome
|
|
134
|
+
finally:
|
|
135
|
+
if circuit_key is not None and runtime.circuits is not None:
|
|
136
|
+
if aborted:
|
|
137
|
+
runtime.circuits.record_aborted(circuit_key)
|
|
138
|
+
else:
|
|
139
|
+
runtime.circuits.record(circuit_key, outcome)
|
|
140
|
+
raise AssertionError("unreachable") # pragma: no cover - ExitStack never swallows
|
|
141
|
+
|
|
142
|
+
def _hops(
|
|
143
|
+
self, request: RequestView, send: SyncSend, deadline: Deadline, observation: CallObservation
|
|
144
|
+
) -> tuple[ResponseView | None, Outcome]:
|
|
145
|
+
plan = self._plan
|
|
146
|
+
replayable = self._freeze_if_needed(request)
|
|
147
|
+
history: list[Attempt] = []
|
|
148
|
+
while True:
|
|
149
|
+
response, outcome = self._attempts(request, send, deadline, observation, history, replayable)
|
|
150
|
+
if response is None or outcome.exception is not None:
|
|
151
|
+
return response, outcome
|
|
152
|
+
if plan.config.redirects.value != "owned":
|
|
153
|
+
return response, outcome
|
|
154
|
+
step = plan_redirect(response, request)
|
|
155
|
+
if step is None:
|
|
156
|
+
return response, outcome
|
|
157
|
+
if observation.hops >= plan.config.max_redirects:
|
|
158
|
+
raise TooManyRedirectsError(plan.config.max_redirects)
|
|
159
|
+
if step.needs_body_replay and not replayable:
|
|
160
|
+
return response, outcome
|
|
161
|
+
self._norm.discard(response)
|
|
162
|
+
apply_redirect(request, step)
|
|
163
|
+
self._telemetry.redirect_hop(observation)
|
|
164
|
+
|
|
165
|
+
def _freeze_if_needed(self, request: RequestView) -> bool:
|
|
166
|
+
plan = self._plan
|
|
167
|
+
needs_replay = plan.retry_policy is not None or plan.config.redirects.value == "owned"
|
|
168
|
+
if not needs_replay:
|
|
169
|
+
return True
|
|
170
|
+
return self._norm.freeze(request)
|
|
171
|
+
|
|
172
|
+
def _attempts(
|
|
173
|
+
self,
|
|
174
|
+
request: RequestView,
|
|
175
|
+
send: SyncSend,
|
|
176
|
+
deadline: Deadline,
|
|
177
|
+
observation: CallObservation,
|
|
178
|
+
history: list[Attempt],
|
|
179
|
+
replayable: bool,
|
|
180
|
+
) -> tuple[ResponseView | None, Outcome]:
|
|
181
|
+
plan = self._plan
|
|
182
|
+
runtime = self._runtime
|
|
183
|
+
info = request.info
|
|
184
|
+
caller = request.caller_timeouts()
|
|
185
|
+
while True:
|
|
186
|
+
remaining = deadline.remaining()
|
|
187
|
+
if remaining is not None and remaining <= 0:
|
|
188
|
+
raise DeadlineExceededError(deadline.total or 0.0)
|
|
189
|
+
timeouts = plan.planner.plan(remaining=remaining, caller=caller)
|
|
190
|
+
request.apply_timeouts(timeouts)
|
|
191
|
+
if plan.config.deadline_header is not None and remaining is not None:
|
|
192
|
+
request.headers[plan.config.deadline_header] = deadline_header_value(remaining)
|
|
193
|
+
attempt_started = runtime.clock()
|
|
194
|
+
observation.attempts += 1
|
|
195
|
+
response: ResponseView | None = None
|
|
196
|
+
try:
|
|
197
|
+
native_response = send(request)
|
|
198
|
+
response = self._norm.wrap_response(native_response)
|
|
199
|
+
outcome = self._norm.classify_response(response)
|
|
200
|
+
except CallError:
|
|
201
|
+
raise
|
|
202
|
+
except TimeoutError as error:
|
|
203
|
+
outcome = Outcome(kind=FailureKind.TOTAL_TIMEOUT, exception=error)
|
|
204
|
+
except Exception as error:
|
|
205
|
+
outcome = Outcome(kind=self._norm.classify_error(error), exception=error)
|
|
206
|
+
attempt = Attempt(
|
|
207
|
+
index=len(history) + 1,
|
|
208
|
+
started=attempt_started,
|
|
209
|
+
duration=runtime.clock() - attempt_started,
|
|
210
|
+
outcome=outcome,
|
|
211
|
+
hop=observation.hops,
|
|
212
|
+
)
|
|
213
|
+
history.append(attempt)
|
|
214
|
+
if plan.emit_attempt_metrics:
|
|
215
|
+
self._telemetry.attempt_end(info, attempt)
|
|
216
|
+
if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired:
|
|
217
|
+
raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception
|
|
218
|
+
if plan.retry_policy is None:
|
|
219
|
+
return response, outcome
|
|
220
|
+
decision = plan.retry_policy.decide(
|
|
221
|
+
info=info,
|
|
222
|
+
history=history,
|
|
223
|
+
remaining=deadline.remaining(),
|
|
224
|
+
replayable=replayable,
|
|
225
|
+
rng=runtime.rng,
|
|
226
|
+
)
|
|
227
|
+
if not decision.retry:
|
|
228
|
+
if decision.reason in SKIP_REASONS:
|
|
229
|
+
self._telemetry.retry_skipped(decision.reason)
|
|
230
|
+
return response, outcome
|
|
231
|
+
if runtime.retry_budgets is not None and not runtime.retry_budgets.try_spend(info.origin):
|
|
232
|
+
self._telemetry.retry_skipped("budget")
|
|
233
|
+
return response, outcome
|
|
234
|
+
if response is not None:
|
|
235
|
+
self._norm.discard(response)
|
|
236
|
+
self._norm.rewind(request)
|
|
237
|
+
if decision.delay > 0:
|
|
238
|
+
time.sleep(decision.delay)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
__all__ = ["SyncAttemptEngine", "SyncSend"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Kernel error hierarchy.
|
|
2
|
+
|
|
3
|
+
Errors raised on the call path (``CallError`` subclasses) are translated by each
|
|
4
|
+
adapter into classes that ALSO inherit the native client's error family, so a
|
|
5
|
+
user's ``except httpx.HTTPError`` keeps working. Errors raised at build time are
|
|
6
|
+
plain kernel errors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClientwrightError(Exception):
|
|
13
|
+
"""Base class for everything clientwright raises on its own authority."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownAdapterError(ClientwrightError):
|
|
17
|
+
"""Requested adapter name is not registered."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, name: str, known: tuple[str, ...]) -> None:
|
|
20
|
+
super().__init__(f"Unknown adapter {name!r}; registered adapters: {', '.join(known) or '<none>'}")
|
|
21
|
+
self.name = name
|
|
22
|
+
self.known = known
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnsupportedCapabilityError(ClientwrightError):
|
|
26
|
+
"""Config asks for something the chosen adapter cannot express (STRICT mode)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NativeConfigError(ClientwrightError):
|
|
30
|
+
"""Base for invalid native passthrough configuration."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnknownNativeSlotError(NativeConfigError):
|
|
34
|
+
def __init__(self, slot: str, known: tuple[str, ...]) -> None:
|
|
35
|
+
super().__init__(f"Unknown native slot {slot!r}; adapter slots: {', '.join(known)}")
|
|
36
|
+
self.slot = slot
|
|
37
|
+
self.known = known
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReservedNativeKeyError(NativeConfigError):
|
|
41
|
+
def __init__(self, slot: str, key: str, hint: str) -> None:
|
|
42
|
+
super().__init__(f"Native key {key!r} in slot {slot!r} is owned by clientwright: {hint}")
|
|
43
|
+
self.slot = slot
|
|
44
|
+
self.key = key
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UnknownNativeKeyError(NativeConfigError):
|
|
48
|
+
def __init__(self, slot: str, key: str, suggestion: str | None = None) -> None:
|
|
49
|
+
hint = f"; did you mean {suggestion!r}?" if suggestion else ""
|
|
50
|
+
super().__init__(f"Native slot {slot!r} accepts no parameter {key!r}{hint}")
|
|
51
|
+
self.slot = slot
|
|
52
|
+
self.key = key
|
|
53
|
+
self.suggestion = suggestion
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NativeConfigConflictError(NativeConfigError):
|
|
57
|
+
def __init__(self, slot: str, key: str, config_field: str) -> None:
|
|
58
|
+
super().__init__(
|
|
59
|
+
f"Native key {key!r} in slot {slot!r} conflicts with explicitly set config field {config_field!r}; "
|
|
60
|
+
f"set it in exactly one place"
|
|
61
|
+
)
|
|
62
|
+
self.slot = slot
|
|
63
|
+
self.key = key
|
|
64
|
+
self.config_field = config_field
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CallError(ClientwrightError):
|
|
68
|
+
"""Base for errors raised while executing a call; adapters dual-inherit these."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CircuitOpenError(CallError):
|
|
72
|
+
"""Local refusal: the circuit for this key is open."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, key: str, retry_after: float | None = None) -> None:
|
|
75
|
+
suffix = f" (retry in ~{retry_after:.1f}s)" if retry_after is not None else ""
|
|
76
|
+
super().__init__(f"Circuit open for {key!r}{suffix}")
|
|
77
|
+
self.key = key
|
|
78
|
+
self.retry_after = retry_after
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DeadlineExceededError(CallError):
|
|
82
|
+
"""The total deadline of the logical call was exhausted."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, total: float) -> None:
|
|
85
|
+
super().__init__(f"Deadline of {total:.3f}s exhausted")
|
|
86
|
+
self.total = total
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TooManyRedirectsError(CallError):
|
|
90
|
+
def __init__(self, hops: int) -> None:
|
|
91
|
+
super().__init__(f"Exceeded {hops} redirect hops")
|
|
92
|
+
self.hops = hops
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class NotReplayableError(CallError):
|
|
96
|
+
"""The request body cannot be replayed, so the required repeat is impossible."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
"CallError",
|
|
101
|
+
"CircuitOpenError",
|
|
102
|
+
"ClientwrightError",
|
|
103
|
+
"DeadlineExceededError",
|
|
104
|
+
"NativeConfigConflictError",
|
|
105
|
+
"NativeConfigError",
|
|
106
|
+
"NotReplayableError",
|
|
107
|
+
"ReservedNativeKeyError",
|
|
108
|
+
"TooManyRedirectsError",
|
|
109
|
+
"UnknownAdapterError",
|
|
110
|
+
"UnknownNativeKeyError",
|
|
111
|
+
"UnknownNativeSlotError",
|
|
112
|
+
"UnsupportedCapabilityError",
|
|
113
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Transport-neutral data model shared by engines, policies and adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
10
|
+
|
|
11
|
+
# Methods whose semantics make a repeat safe without an idempotency key (RFC 9110).
|
|
12
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FailureKind(StrEnum):
|
|
16
|
+
"""Shared alphabet of call outcomes.
|
|
17
|
+
|
|
18
|
+
An alphabet, not a shared partition: each adapter declares which kinds it
|
|
19
|
+
can actually emit (``AdapterCapabilities.emits``) and which finer kinds
|
|
20
|
+
collapse into coarser ones (``AdapterCapabilities.collapses``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
CONNECT_TIMEOUT = "connect_timeout"
|
|
24
|
+
READ_TIMEOUT = "read_timeout"
|
|
25
|
+
WRITE_TIMEOUT = "write_timeout"
|
|
26
|
+
POOL_TIMEOUT = "pool_timeout"
|
|
27
|
+
TOTAL_TIMEOUT = "total_timeout"
|
|
28
|
+
CONNECT_ERROR = "connect_error"
|
|
29
|
+
DNS_ERROR = "dns_error"
|
|
30
|
+
TLS_ERROR = "tls_error"
|
|
31
|
+
PROTOCOL_ERROR = "protocol_error"
|
|
32
|
+
DISCONNECTED = "disconnected"
|
|
33
|
+
BODY_ERROR = "body_error"
|
|
34
|
+
STATUS = "status"
|
|
35
|
+
CANCELLED = "cancelled"
|
|
36
|
+
CIRCUIT_OPEN = "circuit_open"
|
|
37
|
+
UNKNOWN = "unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CircuitKey(StrEnum):
|
|
41
|
+
"""Granularity of the circuit-breaker key."""
|
|
42
|
+
|
|
43
|
+
ORIGIN = "origin"
|
|
44
|
+
ORIGIN_ROUTE = "origin_route"
|
|
45
|
+
ORIGIN_METHOD = "origin_method"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Outcome:
|
|
50
|
+
"""Result of a single attempt. ``kind is None`` means success."""
|
|
51
|
+
|
|
52
|
+
kind: FailureKind | None
|
|
53
|
+
status_code: int | None = None
|
|
54
|
+
retry_after: float | None = None
|
|
55
|
+
exception: BaseException | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ok(self) -> bool:
|
|
59
|
+
return self.kind is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ResolvedTimeouts:
|
|
64
|
+
"""Per-attempt timeout plan handed to the adapter before each send.
|
|
65
|
+
|
|
66
|
+
``attempt`` is the hard ceiling of the whole attempt; async engines enforce
|
|
67
|
+
it with task cancellation, sync engines can only clamp the phases below.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
connect: float | None = None
|
|
71
|
+
read: float | None = None
|
|
72
|
+
write: float | None = None
|
|
73
|
+
pool_acquire: float | None = None
|
|
74
|
+
attempt: float | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class ConnMetrics:
|
|
79
|
+
"""Optional per-connection timings; ``None`` fields mean the adapter cannot see them."""
|
|
80
|
+
|
|
81
|
+
dns: float | None = None
|
|
82
|
+
connect: float | None = None
|
|
83
|
+
tls: float | None = None
|
|
84
|
+
pool_wait: float | None = None
|
|
85
|
+
reused: bool | None = None
|
|
86
|
+
http_version: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class Attempt:
|
|
91
|
+
"""One physical attempt inside a logical call."""
|
|
92
|
+
|
|
93
|
+
index: int
|
|
94
|
+
started: float
|
|
95
|
+
duration: float
|
|
96
|
+
outcome: Outcome
|
|
97
|
+
hop: int = 0
|
|
98
|
+
conn: ConnMetrics | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class RequestInfo:
|
|
103
|
+
"""Low-cardinality identity of a logical call."""
|
|
104
|
+
|
|
105
|
+
method: str
|
|
106
|
+
origin: str
|
|
107
|
+
url: str
|
|
108
|
+
route: str | None = None
|
|
109
|
+
idempotent: bool = True
|
|
110
|
+
|
|
111
|
+
def circuit_key(self, mode: CircuitKey) -> str:
|
|
112
|
+
if mode is CircuitKey.ORIGIN_ROUTE:
|
|
113
|
+
return f"{self.origin} {self.route or 'unknown'}"
|
|
114
|
+
if mode is CircuitKey.ORIGIN_METHOD:
|
|
115
|
+
return f"{self.origin} {self.method}"
|
|
116
|
+
return self.origin
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def origin_of(url: str) -> str:
|
|
120
|
+
"""Normalize a URL to its origin: ``scheme://host:port`` lowercased, default ports explicit."""
|
|
121
|
+
parts = urlsplit(url)
|
|
122
|
+
scheme = (parts.scheme or "http").lower()
|
|
123
|
+
host = (parts.hostname or "").lower()
|
|
124
|
+
port = parts.port if parts.port is not None else _DEFAULT_PORTS.get(scheme)
|
|
125
|
+
return f"{scheme}://{host}:{port}" if port is not None else f"{scheme}://{host}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
"IDEMPOTENT_METHODS",
|
|
130
|
+
"Attempt",
|
|
131
|
+
"CircuitKey",
|
|
132
|
+
"ConnMetrics",
|
|
133
|
+
"FailureKind",
|
|
134
|
+
"Outcome",
|
|
135
|
+
"RequestInfo",
|
|
136
|
+
"ResolvedTimeouts",
|
|
137
|
+
"origin_of",
|
|
138
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Validation of raw native passthrough options.
|
|
2
|
+
|
|
3
|
+
``PoolManager(**kw)``-style constructors swallow any typo silently; here every
|
|
4
|
+
unknown slot, reserved key, misspelled parameter and config collision is a
|
|
5
|
+
loud, actionable error at build time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
|
|
14
|
+
from .config import NativeOptions
|
|
15
|
+
from .errors import (
|
|
16
|
+
NativeConfigConflictError,
|
|
17
|
+
ReservedNativeKeyError,
|
|
18
|
+
UnknownNativeKeyError,
|
|
19
|
+
UnknownNativeSlotError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _suggest(key: str, candidates: list[str]) -> str | None:
|
|
24
|
+
matches = difflib.get_close_matches(key, candidates, n=1, cutoff=0.6)
|
|
25
|
+
return matches[0] if matches else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _signature_keys(target: Callable[..., object]) -> tuple[frozenset[str], bool]:
|
|
29
|
+
"""Keyword-acceptable parameter names and whether **kwargs is present."""
|
|
30
|
+
signature = inspect.signature(target)
|
|
31
|
+
names = set()
|
|
32
|
+
has_var_keyword = False
|
|
33
|
+
for parameter in signature.parameters.values():
|
|
34
|
+
if parameter.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
|
|
35
|
+
names.add(parameter.name)
|
|
36
|
+
elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
37
|
+
has_var_keyword = True
|
|
38
|
+
names.discard("self")
|
|
39
|
+
return frozenset(names), has_var_keyword
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_native(
|
|
43
|
+
native: NativeOptions,
|
|
44
|
+
*,
|
|
45
|
+
slots: frozenset[str],
|
|
46
|
+
reserved: Mapping[str, Mapping[str, str]],
|
|
47
|
+
allowed: Mapping[str, frozenset[str] | None],
|
|
48
|
+
signature_targets: Mapping[str, Callable[..., object]],
|
|
49
|
+
config_conflicts: Mapping[str, Mapping[str, str]],
|
|
50
|
+
) -> dict[str, dict[str, object]]:
|
|
51
|
+
"""Validate and return per-slot native kwargs.
|
|
52
|
+
|
|
53
|
+
``config_conflicts`` maps slot -> {native_key: explicitly-set config field};
|
|
54
|
+
the adapter builds it from fields the user actually set (not UNSET), so the
|
|
55
|
+
"conflict is an error" rule fires exactly when both sides were specified.
|
|
56
|
+
"""
|
|
57
|
+
validated: dict[str, dict[str, object]] = {}
|
|
58
|
+
for slot, values in native.slots.items():
|
|
59
|
+
if slot not in slots:
|
|
60
|
+
raise UnknownNativeSlotError(slot, tuple(sorted(slots)))
|
|
61
|
+
reserved_keys = reserved.get(slot, {})
|
|
62
|
+
allowlist = allowed.get(slot)
|
|
63
|
+
signature_keys: frozenset[str] | None = None
|
|
64
|
+
has_var_keyword = False
|
|
65
|
+
if allowlist is None and slot in signature_targets:
|
|
66
|
+
signature_keys, has_var_keyword = _signature_keys(signature_targets[slot])
|
|
67
|
+
conflicts = config_conflicts.get(slot, {})
|
|
68
|
+
slot_values: dict[str, object] = {}
|
|
69
|
+
for key, value in values.items():
|
|
70
|
+
if key in reserved_keys:
|
|
71
|
+
raise ReservedNativeKeyError(slot, key, reserved_keys[key])
|
|
72
|
+
if key in conflicts:
|
|
73
|
+
raise NativeConfigConflictError(slot, key, conflicts[key])
|
|
74
|
+
if allowlist is not None:
|
|
75
|
+
if key not in allowlist:
|
|
76
|
+
raise UnknownNativeKeyError(slot, key, _suggest(key, sorted(allowlist)))
|
|
77
|
+
elif signature_keys is not None and not has_var_keyword and key not in signature_keys:
|
|
78
|
+
raise UnknownNativeKeyError(slot, key, _suggest(key, sorted(signature_keys)))
|
|
79
|
+
slot_values[key] = value
|
|
80
|
+
validated[slot] = slot_values
|
|
81
|
+
return validated
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["validate_native"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Ambient per-call options for adapters without a native per-request channel.
|
|
2
|
+
|
|
3
|
+
httpx carries route/idempotency in ``request.extensions``; aiohttp, requests
|
|
4
|
+
and urllib3 have no such container, so the options travel through a ContextVar
|
|
5
|
+
set by the caller around the call. A task or thread-local context started
|
|
6
|
+
under ``call_options`` inherits it; siblings do not.
|
|
7
|
+
|
|
8
|
+
with call_options(route="/users/{id}", idempotent=True):
|
|
9
|
+
session.post(f"/users/{user_id}")
|
|
10
|
+
|
|
11
|
+
One shared channel on purpose: the block applies to whichever clientwright
|
|
12
|
+
client sends inside it, uniformly across adapters.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Generator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class CallOptions:
|
|
25
|
+
route: str | None = None
|
|
26
|
+
idempotent: bool | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_CALL_OPTIONS: ContextVar[CallOptions | None] = ContextVar("clientwright_call_options", default=None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def current_call_options() -> CallOptions | None:
|
|
33
|
+
return _CALL_OPTIONS.get()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def call_options(*, route: str | None = None, idempotent: bool | None = None) -> Generator[CallOptions]:
|
|
38
|
+
options = CallOptions(route=route, idempotent=idempotent)
|
|
39
|
+
token = _CALL_OPTIONS.set(options)
|
|
40
|
+
try:
|
|
41
|
+
yield options
|
|
42
|
+
finally:
|
|
43
|
+
_CALL_OPTIONS.reset(token)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["CallOptions", "call_options", "current_call_options"]
|