clientwright 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. clientwright/__init__.py +179 -0
  2. clientwright/__version__.py +1 -0
  3. clientwright/adapters/__init__.py +3 -0
  4. clientwright/adapters/_httpx_shared.py +831 -0
  5. clientwright/adapters/_lazy.py +30 -0
  6. clientwright/adapters/aiohttp/__init__.py +45 -0
  7. clientwright/adapters/aiohttp/_imports.py +30 -0
  8. clientwright/adapters/aiohttp/adapter.py +236 -0
  9. clientwright/adapters/aiohttp/capabilities.py +81 -0
  10. clientwright/adapters/aiohttp/classify.py +59 -0
  11. clientwright/adapters/aiohttp/errors.py +57 -0
  12. clientwright/adapters/aiohttp/middleware.py +103 -0
  13. clientwright/adapters/aiohttp/normalize.py +64 -0
  14. clientwright/adapters/aiohttp/options.py +16 -0
  15. clientwright/adapters/aiohttp/trace.py +109 -0
  16. clientwright/adapters/aiohttp/views.py +108 -0
  17. clientwright/adapters/httpx/__init__.py +45 -0
  18. clientwright/adapters/httpx/_imports.py +17 -0
  19. clientwright/adapters/httpx/adapter.py +39 -0
  20. clientwright/adapters/httpx/capabilities.py +9 -0
  21. clientwright/adapters/httpx/classify.py +14 -0
  22. clientwright/adapters/httpx/errors.py +35 -0
  23. clientwright/adapters/httpx/normalize.py +27 -0
  24. clientwright/adapters/httpx/normalize_sync.py +27 -0
  25. clientwright/adapters/httpx/transport.py +40 -0
  26. clientwright/adapters/httpx/views.py +46 -0
  27. clientwright/adapters/httpx2/__init__.py +46 -0
  28. clientwright/adapters/httpx2/_imports.py +18 -0
  29. clientwright/adapters/httpx2/adapter.py +37 -0
  30. clientwright/adapters/httpx2/capabilities.py +9 -0
  31. clientwright/adapters/httpx2/classify.py +14 -0
  32. clientwright/adapters/httpx2/errors.py +36 -0
  33. clientwright/adapters/httpx2/normalize.py +27 -0
  34. clientwright/adapters/httpx2/normalize_sync.py +27 -0
  35. clientwright/adapters/httpx2/transport.py +35 -0
  36. clientwright/adapters/httpx2/views.py +45 -0
  37. clientwright/adapters/observability/__init__.py +26 -0
  38. clientwright/adapters/observability/_metrics/__init__.py +1 -0
  39. clientwright/adapters/observability/_metrics/prometheus.py +200 -0
  40. clientwright/adapters/observability/_tracing/__init__.py +3 -0
  41. clientwright/adapters/observability/_tracing/otel.py +61 -0
  42. clientwright/adapters/requests/__init__.py +45 -0
  43. clientwright/adapters/requests/_imports.py +21 -0
  44. clientwright/adapters/requests/adapter.py +206 -0
  45. clientwright/adapters/requests/capabilities.py +80 -0
  46. clientwright/adapters/requests/classify.py +62 -0
  47. clientwright/adapters/requests/errors.py +40 -0
  48. clientwright/adapters/requests/normalize.py +63 -0
  49. clientwright/adapters/requests/views.py +121 -0
  50. clientwright/adapters/urllib3/__init__.py +48 -0
  51. clientwright/adapters/urllib3/_imports.py +18 -0
  52. clientwright/adapters/urllib3/adapter.py +260 -0
  53. clientwright/adapters/urllib3/capabilities.py +86 -0
  54. clientwright/adapters/urllib3/classify.py +46 -0
  55. clientwright/adapters/urllib3/errors.py +55 -0
  56. clientwright/adapters/urllib3/normalize.py +51 -0
  57. clientwright/adapters/urllib3/views.py +119 -0
  58. clientwright/contrib/__init__.py +3 -0
  59. clientwright/contrib/deadline.py +107 -0
  60. clientwright/contrib/dishka.py +80 -0
  61. clientwright/core/__init__.py +6 -0
  62. clientwright/core/balancer/__init__.py +1 -0
  63. clientwright/core/balancer/policy.py +23 -0
  64. clientwright/core/capabilities.py +156 -0
  65. clientwright/core/config.py +367 -0
  66. clientwright/core/contracts/__init__.py +33 -0
  67. clientwright/core/contracts/adapter.py +62 -0
  68. clientwright/core/contracts/context.py +31 -0
  69. clientwright/core/contracts/message.py +118 -0
  70. clientwright/core/contracts/observability.py +92 -0
  71. clientwright/core/contracts/settings.py +140 -0
  72. clientwright/core/engine/__init__.py +1 -0
  73. clientwright/core/engine/aio.py +246 -0
  74. clientwright/core/engine/base.py +65 -0
  75. clientwright/core/engine/redirects.py +64 -0
  76. clientwright/core/engine/suppress.py +30 -0
  77. clientwright/core/engine/sync.py +241 -0
  78. clientwright/core/errors.py +113 -0
  79. clientwright/core/model.py +138 -0
  80. clientwright/core/native.py +84 -0
  81. clientwright/core/options.py +46 -0
  82. clientwright/core/plan.py +190 -0
  83. clientwright/core/policy/__init__.py +1 -0
  84. clientwright/core/policy/budget.py +76 -0
  85. clientwright/core/policy/circuit.py +169 -0
  86. clientwright/core/policy/concurrency.py +98 -0
  87. clientwright/core/policy/retry.py +80 -0
  88. clientwright/core/policy/timeout.py +64 -0
  89. clientwright/core/registry.py +75 -0
  90. clientwright/core/telemetry/__init__.py +6 -0
  91. clientwright/core/telemetry/emitter.py +163 -0
  92. clientwright/core/telemetry/names.py +61 -0
  93. clientwright/core/telemetry/null.py +89 -0
  94. clientwright/core/telemetry/redaction.py +24 -0
  95. clientwright/core/testing/__init__.py +7 -0
  96. clientwright/core/testing/doubles.py +107 -0
  97. clientwright/core/testing/origin.py +201 -0
  98. clientwright/py.typed +0 -0
  99. clientwright-0.1.0.dist-info/METADATA +210 -0
  100. clientwright-0.1.0.dist-info/RECORD +102 -0
  101. clientwright-0.1.0.dist-info/WHEEL +4 -0
  102. clientwright-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,92 @@
1
+ """SDK-free observability seams.
2
+
3
+ The kernel records everything through these protocols; concrete backends
4
+ (Prometheus, OpenTelemetry) live in ``clientwright.adapters.observability``
5
+ behind extras and are never imported by the core.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping, MutableMapping
11
+ from typing import Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class ClientMetricsProtocol(Protocol):
16
+ """Sink for the frozen ``http_client_*`` metric families.
17
+
18
+ Label semantics: ``outcome`` is ``"success"`` or a ``FailureKind`` value;
19
+ ``status`` is the HTTP status as a string or ``"none"`` when the call
20
+ produced no response; ``adapter``/``seam`` are per-adapter constants.
21
+ """
22
+
23
+ def record_call(
24
+ self,
25
+ *,
26
+ service: str,
27
+ adapter: str,
28
+ seam: str,
29
+ method: str,
30
+ origin: str,
31
+ route: str,
32
+ status: str,
33
+ outcome: str,
34
+ duration: float,
35
+ ) -> None: ...
36
+
37
+ def record_body_duration(
38
+ self,
39
+ *,
40
+ service: str,
41
+ adapter: str,
42
+ seam: str,
43
+ method: str,
44
+ origin: str,
45
+ route: str,
46
+ duration: float,
47
+ ) -> None: ...
48
+
49
+ def record_attempt(
50
+ self,
51
+ *,
52
+ service: str,
53
+ adapter: str,
54
+ seam: str,
55
+ method: str,
56
+ origin: str,
57
+ outcome: str,
58
+ duration: float,
59
+ ) -> None: ...
60
+
61
+ def inflight_delta(self, *, service: str, adapter: str, seam: str, origin: str, delta: int) -> None: ...
62
+
63
+ def record_circuit_state(self, *, service: str, adapter: str, key: str, state: str) -> None: ...
64
+
65
+ def record_redirect_hop(self, *, service: str, adapter: str, seam: str) -> None: ...
66
+
67
+ def record_retry_skipped(self, *, service: str, adapter: str, seam: str, reason: str) -> None: ...
68
+
69
+ def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None: ...
70
+
71
+
72
+ @runtime_checkable
73
+ class SpanProtocol(Protocol):
74
+ """One client span; ended exactly once, in a finally block."""
75
+
76
+ def set_attribute(self, key: str, value: str | int | float | bool) -> None: ...
77
+
78
+ def record_failure(self, description: str) -> None: ...
79
+
80
+ def end(self) -> None: ...
81
+
82
+
83
+ @runtime_checkable
84
+ class TracerProtocol(Protocol):
85
+ """Opens client spans and injects propagation headers."""
86
+
87
+ def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> SpanProtocol: ...
88
+
89
+ def inject_context(self, headers: MutableMapping[str, str]) -> None: ...
90
+
91
+
92
+ __all__ = ["ClientMetricsProtocol", "SpanProtocol", "TracerProtocol"]
@@ -0,0 +1,140 @@
1
+ """Structural settings protocols for service integration.
2
+
3
+ Services keep their own settings models (pydantic or otherwise); anything with
4
+ these read-only properties satisfies the contract - the org convention set by
5
+ grpc-client-kit. ``client_config_from_settings`` converts the structural shape
6
+ into a ``ClientConfig``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from ..config import (
14
+ CircuitBreakerConfig,
15
+ ClientConfig,
16
+ ObservabilityConfig,
17
+ PoolConfig,
18
+ RetryConfig,
19
+ TimeoutConfig,
20
+ TlsConfig,
21
+ )
22
+
23
+
24
+ @runtime_checkable
25
+ class RetrySettingsProtocol(Protocol):
26
+ @property
27
+ def max_attempts(self) -> int: ...
28
+
29
+ @property
30
+ def initial_backoff(self) -> float: ...
31
+
32
+ @property
33
+ def max_backoff(self) -> float: ...
34
+
35
+ @property
36
+ def backoff_multiplier(self) -> float: ...
37
+
38
+
39
+ @runtime_checkable
40
+ class CircuitBreakerSettingsProtocol(Protocol):
41
+ @property
42
+ def fail_threshold(self) -> int: ...
43
+
44
+ @property
45
+ def recovery_timeout(self) -> float: ...
46
+
47
+ @property
48
+ def half_open_max_calls(self) -> int: ...
49
+
50
+
51
+ @runtime_checkable
52
+ class ClientSettingsProtocol(Protocol):
53
+ """Mirror of the legacy ``RestClientSettingsProtocol`` reachable surface."""
54
+
55
+ @property
56
+ def base_url(self) -> str: ...
57
+
58
+ @property
59
+ def timeout_seconds(self) -> float: ...
60
+
61
+ @property
62
+ def connect_timeout_seconds(self) -> float: ...
63
+
64
+ @property
65
+ def max_connections(self) -> int: ...
66
+
67
+ @property
68
+ def max_keepalive_connections(self) -> int: ...
69
+
70
+ @property
71
+ def enable_http2(self) -> bool: ...
72
+
73
+ @property
74
+ def verify(self) -> bool: ...
75
+
76
+ @property
77
+ def logging_enabled(self) -> bool: ...
78
+
79
+ @property
80
+ def metrics_enabled(self) -> bool: ...
81
+
82
+ @property
83
+ def tracing_enabled(self) -> bool: ...
84
+
85
+ @property
86
+ def retry(self) -> RetrySettingsProtocol | None: ...
87
+
88
+ @property
89
+ def circuit_breaker(self) -> CircuitBreakerSettingsProtocol | None: ...
90
+
91
+
92
+ def client_config_from_settings(settings: ClientSettingsProtocol, service_name: str) -> ClientConfig:
93
+ """Build a ClientConfig from the structural settings shape used by services."""
94
+ retry_settings = settings.retry
95
+ retry = (
96
+ RetryConfig(
97
+ max_attempts=retry_settings.max_attempts,
98
+ initial_backoff=retry_settings.initial_backoff,
99
+ max_backoff=retry_settings.max_backoff,
100
+ multiplier=retry_settings.backoff_multiplier,
101
+ )
102
+ if retry_settings is not None
103
+ else None
104
+ )
105
+ breaker_settings = settings.circuit_breaker
106
+ circuit_breaker = (
107
+ CircuitBreakerConfig(
108
+ fail_threshold=breaker_settings.fail_threshold,
109
+ recovery_timeout=breaker_settings.recovery_timeout,
110
+ half_open_max_calls=breaker_settings.half_open_max_calls,
111
+ )
112
+ if breaker_settings is not None
113
+ else None
114
+ )
115
+ return ClientConfig(
116
+ service_name=service_name,
117
+ base_url=settings.base_url,
118
+ timeout=TimeoutConfig(total=settings.timeout_seconds, connect=settings.connect_timeout_seconds),
119
+ pool=PoolConfig(
120
+ max_connections=settings.max_connections,
121
+ max_keepalive=settings.max_keepalive_connections,
122
+ http2=settings.enable_http2,
123
+ ),
124
+ retry=retry,
125
+ circuit_breaker=circuit_breaker,
126
+ tls=TlsConfig(verify=settings.verify),
127
+ observability=ObservabilityConfig(
128
+ logging=settings.logging_enabled,
129
+ metrics=settings.metrics_enabled,
130
+ tracing=settings.tracing_enabled,
131
+ ),
132
+ )
133
+
134
+
135
+ __all__ = [
136
+ "CircuitBreakerSettingsProtocol",
137
+ "ClientSettingsProtocol",
138
+ "RetrySettingsProtocol",
139
+ "client_config_from_settings",
140
+ ]
@@ -0,0 +1 @@
1
+ """Attempt engines: the only retry/redirect/deadline loops in the library."""
@@ -0,0 +1,246 @@
1
+ """The single asynchronous attempt loop of the whole library.
2
+
3
+ Order is baked in and not configurable:
4
+ deadline -> headers -> per-origin slot -> circuit check (outside retry)
5
+ -> [hop loop -> attempt loop] -> circuit record (one signal) -> telemetry end.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from collections.abc import Awaitable, Callable
13
+ from contextlib import AsyncExitStack
14
+ from typing import Any
15
+
16
+ from ..contracts.adapter import AdapterDeps
17
+ from ..contracts.message import AsyncNormalizer, RequestView, ResponseView
18
+ from ..errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
19
+ from ..model import Attempt, FailureKind, Outcome
20
+ from ..plan import CallPlan, ClientRuntime
21
+ from ..policy.budget import Deadline
22
+ from ..telemetry.emitter import CallObservation, ClientTelemetry
23
+ from .base import SKIP_REASONS, deadline_header_value, inject_static_headers
24
+ from .redirects import apply_redirect, plan_redirect
25
+
26
+ type AsyncSend = Callable[[RequestView], Awaitable[Any]]
27
+ type ErrorTranslator = Callable[[CallError], BaseException]
28
+
29
+ logger = logging.getLogger("clientwright.engine")
30
+
31
+
32
+ class AsyncAttemptEngine:
33
+ """Runs one logical call: retries, owned redirects, deadline, telemetry."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ plan: CallPlan,
39
+ runtime: ClientRuntime,
40
+ telemetry: ClientTelemetry,
41
+ normalizer: AsyncNormalizer,
42
+ deps: AdapterDeps,
43
+ translate: ErrorTranslator,
44
+ ) -> None:
45
+ self._plan = plan
46
+ self._runtime = runtime
47
+ self._telemetry = telemetry
48
+ self._norm = normalizer
49
+ self._deps = deps
50
+ self._translate = translate
51
+
52
+ async def run(self, native_request: Any, send: AsyncSend) -> Any:
53
+ clock = self._runtime.clock
54
+ request = self._norm.wrap_request(native_request)
55
+ info = request.info
56
+ started = clock()
57
+ observation = self._telemetry.call_start(info, started)
58
+ final_outcome = Outcome(kind=FailureKind.UNKNOWN)
59
+ response: ResponseView | None = None
60
+ try:
61
+ response, final_outcome = await self._admitted(request, send, observation)
62
+ except CallError as error:
63
+ final_outcome = self._call_error_outcome(error)
64
+ raise self._translate(error) from error
65
+ except BaseException as error:
66
+ if final_outcome.exception is not error:
67
+ final_outcome = Outcome(kind=self._norm.classify_error(error), exception=error)
68
+ raise
69
+ finally:
70
+ self._telemetry.call_end(observation, info, final_outcome, clock() - started)
71
+ if final_outcome.exception is not None:
72
+ raise final_outcome.exception
73
+ assert response is not None # a call without exception always has a response
74
+ self._wrap_stream(response, info)
75
+ return response.native
76
+
77
+ def _call_error_outcome(self, error: CallError) -> Outcome:
78
+ if isinstance(error, CircuitOpenError):
79
+ return Outcome(kind=FailureKind.CIRCUIT_OPEN, exception=error)
80
+ if isinstance(error, DeadlineExceededError):
81
+ return Outcome(kind=FailureKind.TOTAL_TIMEOUT, exception=error)
82
+ return Outcome(kind=FailureKind.UNKNOWN, exception=error)
83
+
84
+ def _wrap_stream(self, response: ResponseView, info: Any) -> None:
85
+ def on_done(outcome: Outcome, duration: float) -> None:
86
+ self._telemetry.record_body_duration(info, duration)
87
+
88
+ self._norm.wrap_stream(response, on_done)
89
+
90
+ def _inject_headers(self, request: RequestView) -> None:
91
+ headers = request.headers
92
+ inject_static_headers(headers, dict(self._plan.config.headers))
93
+ for provider in self._deps.header_providers:
94
+ try:
95
+ inject_static_headers(headers, dict(provider()))
96
+ except Exception:
97
+ logger.debug("Header provider %r failed", provider, exc_info=True)
98
+ continue
99
+ self._telemetry.tracer.inject_context(headers)
100
+
101
+ def _deadline(self, request: RequestView) -> Deadline:
102
+ ambient = self._deps.deadline_source.remaining() if self._deps.deadline_source is not None else None
103
+ return Deadline.intersect(self._runtime.clock, self._plan.config.timeout.total, ambient)
104
+
105
+ async def _admitted(
106
+ self, request: RequestView, send: AsyncSend, observation: CallObservation
107
+ ) -> tuple[ResponseView | None, Outcome]:
108
+ plan = self._plan
109
+ runtime = self._runtime
110
+ info = request.info
111
+ deadline = self._deadline(request)
112
+ self._inject_headers(request)
113
+ circuit_key: str | None = None
114
+ async with AsyncExitStack() as stack:
115
+ if plan.use_origin_limiter and runtime.async_limiter is not None:
116
+ await stack.enter_async_context(runtime.async_limiter.acquire(info.origin))
117
+ if runtime.circuits is not None and plan.config.circuit_breaker is not None:
118
+ circuit_key = info.circuit_key(plan.config.circuit_breaker.key)
119
+ runtime.circuits.check(circuit_key)
120
+ if runtime.retry_budgets is not None:
121
+ runtime.retry_budgets.earn(info.origin)
122
+ outcome = Outcome(kind=FailureKind.UNKNOWN)
123
+ aborted = False
124
+ try:
125
+ response, outcome = await self._hops(request, send, deadline, observation)
126
+ except CallError as error:
127
+ outcome = self._call_error_outcome(error)
128
+ raise
129
+ except BaseException:
130
+ # Cancellation or an adapter fault: no classified outcome, so it
131
+ # must be neither a success nor a failure signal for the breaker.
132
+ aborted = True
133
+ raise
134
+ else:
135
+ return response, outcome
136
+ finally:
137
+ if circuit_key is not None and runtime.circuits is not None:
138
+ if aborted:
139
+ runtime.circuits.record_aborted(circuit_key)
140
+ else:
141
+ runtime.circuits.record(circuit_key, outcome)
142
+ raise AssertionError("unreachable") # pragma: no cover - AsyncExitStack never swallows
143
+
144
+ async def _hops(
145
+ self, request: RequestView, send: AsyncSend, deadline: Deadline, observation: CallObservation
146
+ ) -> tuple[ResponseView | None, Outcome]:
147
+ plan = self._plan
148
+ replayable = await self._freeze_if_needed(request)
149
+ history: list[Attempt] = []
150
+ while True:
151
+ response, outcome = await self._attempts(request, send, deadline, observation, history, replayable)
152
+ if response is None or outcome.exception is not None:
153
+ return response, outcome
154
+ if plan.config.redirects.value != "owned":
155
+ return response, outcome
156
+ step = plan_redirect(response, request)
157
+ if step is None:
158
+ return response, outcome
159
+ if observation.hops >= plan.config.max_redirects:
160
+ raise TooManyRedirectsError(plan.config.max_redirects)
161
+ if step.needs_body_replay and not replayable:
162
+ return response, outcome
163
+ await self._norm.discard(response)
164
+ apply_redirect(request, step)
165
+ self._telemetry.redirect_hop(observation)
166
+
167
+ async def _freeze_if_needed(self, request: RequestView) -> bool:
168
+ plan = self._plan
169
+ needs_replay = plan.retry_policy is not None or plan.config.redirects.value == "owned"
170
+ if not needs_replay:
171
+ return True
172
+ return await self._norm.freeze(request)
173
+
174
+ async def _attempts(
175
+ self,
176
+ request: RequestView,
177
+ send: AsyncSend,
178
+ deadline: Deadline,
179
+ observation: CallObservation,
180
+ history: list[Attempt],
181
+ replayable: bool,
182
+ ) -> tuple[ResponseView | None, Outcome]:
183
+ plan = self._plan
184
+ runtime = self._runtime
185
+ info = request.info
186
+ caller = request.caller_timeouts()
187
+ while True:
188
+ remaining = deadline.remaining()
189
+ if remaining is not None and remaining <= 0:
190
+ raise DeadlineExceededError(deadline.total or 0.0)
191
+ timeouts = plan.planner.plan(remaining=remaining, caller=caller)
192
+ request.apply_timeouts(timeouts)
193
+ if plan.config.deadline_header is not None and remaining is not None:
194
+ request.headers[plan.config.deadline_header] = deadline_header_value(remaining)
195
+ attempt_started = runtime.clock()
196
+ observation.attempts += 1
197
+ response: ResponseView | None = None
198
+ try:
199
+ async with asyncio.timeout(timeouts.attempt):
200
+ native_response = await send(request)
201
+ response = self._norm.wrap_response(native_response)
202
+ outcome = self._norm.classify_response(response)
203
+ except CallError:
204
+ raise
205
+ except asyncio.CancelledError:
206
+ raise
207
+ except TimeoutError as error:
208
+ outcome = Outcome(kind=FailureKind.TOTAL_TIMEOUT, exception=error)
209
+ except Exception as error:
210
+ outcome = Outcome(kind=self._norm.classify_error(error), exception=error)
211
+ attempt = Attempt(
212
+ index=len(history) + 1,
213
+ started=attempt_started,
214
+ duration=runtime.clock() - attempt_started,
215
+ outcome=outcome,
216
+ hop=observation.hops,
217
+ )
218
+ history.append(attempt)
219
+ if plan.emit_attempt_metrics:
220
+ self._telemetry.attempt_end(info, attempt)
221
+ if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired:
222
+ raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception
223
+ if plan.retry_policy is None:
224
+ return response, outcome
225
+ decision = plan.retry_policy.decide(
226
+ info=info,
227
+ history=history,
228
+ remaining=deadline.remaining(),
229
+ replayable=replayable,
230
+ rng=runtime.rng,
231
+ )
232
+ if not decision.retry:
233
+ if decision.reason in SKIP_REASONS:
234
+ self._telemetry.retry_skipped(decision.reason)
235
+ return response, outcome
236
+ if runtime.retry_budgets is not None and not runtime.retry_budgets.try_spend(info.origin):
237
+ self._telemetry.retry_skipped("budget")
238
+ return response, outcome
239
+ if response is not None:
240
+ await self._norm.discard(response)
241
+ await self._norm.rewind(request)
242
+ if decision.delay > 0:
243
+ await asyncio.sleep(decision.delay)
244
+
245
+
246
+ __all__ = ["AsyncAttemptEngine", "AsyncSend", "ErrorTranslator"]
@@ -0,0 +1,65 @@
1
+ """Pure helpers shared by the sync and async engines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import email.utils
7
+ from collections.abc import MutableMapping
8
+
9
+ from ..contracts.message import ResponseView
10
+ from ..model import FailureKind, Outcome
11
+
12
+ # Statuses that commonly carry Retry-After.
13
+ _RETRY_AFTER_STATUSES = frozenset({413, 429, 503})
14
+
15
+ # Reasons worth a retry_skipped sentinel: the policy WANTED to retry but could not.
16
+ SKIP_REASONS = frozenset({"method", "non_replayable", "deadline", "budget"})
17
+
18
+
19
+ def parse_retry_after(value: str | None) -> float | None:
20
+ """Seconds from a Retry-After header: integer form or HTTP-date form."""
21
+ if not value:
22
+ return None
23
+ text = value.strip()
24
+ if text.isdigit():
25
+ return float(text)
26
+ try:
27
+ when = email.utils.parsedate_to_datetime(text)
28
+ except (TypeError, ValueError):
29
+ return None
30
+ if when.tzinfo is None:
31
+ when = when.replace(tzinfo=datetime.UTC)
32
+ delta = (when - datetime.datetime.now(datetime.UTC)).total_seconds()
33
+ return max(delta, 0.0)
34
+
35
+
36
+ def default_response_outcome(response: ResponseView) -> Outcome:
37
+ """Default classification: a received response is a failure only when 5xx.
38
+
39
+ Retry-After is captured for throttling and unavailability statuses so the
40
+ retry policy can honor it.
41
+ """
42
+ status = response.status_code
43
+ retry_after = parse_retry_after(response.header("retry-after")) if status in _RETRY_AFTER_STATUSES else None
44
+ kind = FailureKind.STATUS if status >= 500 else None
45
+ return Outcome(kind=kind, status_code=status, retry_after=retry_after)
46
+
47
+
48
+ def inject_static_headers(headers: MutableMapping[str, str], extra: dict[str, str]) -> None:
49
+ """setdefault semantics: caller-set headers always win."""
50
+ for name, value in extra.items():
51
+ if name not in headers:
52
+ headers[name] = value
53
+
54
+
55
+ def deadline_header_value(remaining: float) -> str:
56
+ return str(max(int(remaining * 1000), 0))
57
+
58
+
59
+ __all__ = [
60
+ "SKIP_REASONS",
61
+ "deadline_header_value",
62
+ "default_response_outcome",
63
+ "inject_static_headers",
64
+ "parse_retry_after",
65
+ ]
@@ -0,0 +1,64 @@
1
+ """Owned redirects: the engine follows hops, native following stays disabled.
2
+
3
+ One logical call therefore has ONE deadline, ONE retry budget and ONE circuit
4
+ signal regardless of hop count - on every adapter.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from urllib.parse import urljoin
11
+
12
+ from ..contracts.message import RequestView, ResponseView
13
+ from ..model import origin_of
14
+
15
+ REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
16
+
17
+ # Stripped when a redirect crosses origins, mirroring urllib3's
18
+ # remove_headers_on_redirect and browser behavior.
19
+ CROSS_ORIGIN_STRIP = ("authorization", "proxy-authorization", "cookie")
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class RedirectStep:
24
+ url: str
25
+ method: str | None
26
+ drop_body: bool
27
+ cross_origin: bool
28
+
29
+ @property
30
+ def needs_body_replay(self) -> bool:
31
+ return not self.drop_body
32
+
33
+
34
+ def plan_redirect(response: ResponseView, request: RequestView) -> RedirectStep | None:
35
+ """The next hop, or None when the response is not a followable redirect."""
36
+ if response.status_code not in REDIRECT_STATUSES:
37
+ return None
38
+ location = response.location
39
+ if not location:
40
+ return None
41
+ info = request.info
42
+ url = urljoin(info.url, location)
43
+ method: str | None = None
44
+ drop_body = False
45
+ if response.status_code == 303 or (response.status_code in (301, 302) and info.method == "POST"):
46
+ if info.method != "HEAD":
47
+ method = "GET"
48
+ drop_body = True
49
+ return RedirectStep(
50
+ url=url,
51
+ method=method,
52
+ drop_body=drop_body,
53
+ cross_origin=origin_of(url) != info.origin,
54
+ )
55
+
56
+
57
+ def apply_redirect(request: RequestView, step: RedirectStep) -> None:
58
+ if step.cross_origin:
59
+ for name in CROSS_ORIGIN_STRIP:
60
+ request.headers.pop(name, None)
61
+ request.retarget(step.url, method=step.method, drop_body=step.drop_body)
62
+
63
+
64
+ __all__ = ["CROSS_ORIGIN_STRIP", "REDIRECT_STATUSES", "RedirectStep", "apply_redirect", "plan_redirect"]
@@ -0,0 +1,30 @@
1
+ """ContextVar suppression against double accounting.
2
+
3
+ When one instrumented client physically sits on top of another (requests over
4
+ urllib3) or a seam recurses into itself, the inner layer must stay silent: the
5
+ outer layer owns instrumentation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Generator
11
+ from contextlib import contextmanager
12
+ from contextvars import ContextVar
13
+
14
+ _DEPTH: ContextVar[int] = ContextVar("clientwright_suppress_depth", default=0)
15
+
16
+
17
+ def is_suppressed() -> bool:
18
+ return _DEPTH.get() > 0
19
+
20
+
21
+ @contextmanager
22
+ def suppressed() -> Generator[None]:
23
+ token = _DEPTH.set(_DEPTH.get() + 1)
24
+ try:
25
+ yield
26
+ finally:
27
+ _DEPTH.reset(token)
28
+
29
+
30
+ __all__ = ["is_suppressed", "suppressed"]