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,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"]
|