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,367 @@
|
|
|
1
|
+
"""Universal, transport-free client configuration.
|
|
2
|
+
|
|
3
|
+
Three rules keep this config honest:
|
|
4
|
+
|
|
5
|
+
1. ``UNSET`` is not "disabled": an unset knob defers to the adapter's native
|
|
6
|
+
default and the deferral is visible in the ``ConfigApplicationReport``.
|
|
7
|
+
2. No transport types appear here, so the same config is readable by any adapter.
|
|
8
|
+
3. Anything set but inexpressible on the chosen adapter lands in
|
|
9
|
+
``report.dropped``; under ``UnsupportedPolicy.STRICT`` that fails the build.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
from .model import IDEMPOTENT_METHODS, CircuitKey, FailureKind
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Unset:
|
|
24
|
+
"""Singleton sentinel distinguishing "user set this" from "default applies"."""
|
|
25
|
+
|
|
26
|
+
__slots__ = ()
|
|
27
|
+
_instance: _Unset | None = None
|
|
28
|
+
|
|
29
|
+
def __new__(cls) -> _Unset:
|
|
30
|
+
if cls._instance is None:
|
|
31
|
+
cls._instance = super().__new__(cls)
|
|
32
|
+
return cls._instance
|
|
33
|
+
|
|
34
|
+
def __bool__(self) -> bool:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return "UNSET"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
UNSET: Final = _Unset()
|
|
42
|
+
|
|
43
|
+
type Maybe[T] = T | _Unset
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_set(value: object) -> bool:
|
|
47
|
+
"""True when the value was explicitly provided (is not the UNSET sentinel)."""
|
|
48
|
+
return not isinstance(value, _Unset)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve[T](value: Maybe[T], fallback: T) -> T:
|
|
52
|
+
"""Collapse a Maybe to a concrete value."""
|
|
53
|
+
if isinstance(value, _Unset):
|
|
54
|
+
return fallback
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UnsupportedPolicy(StrEnum):
|
|
59
|
+
IGNORE = "ignore"
|
|
60
|
+
WARN = "warn"
|
|
61
|
+
STRICT = "strict"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RedirectMode(StrEnum):
|
|
65
|
+
OWNED = "owned"
|
|
66
|
+
NATIVE = "native"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CallerOverride(StrEnum):
|
|
70
|
+
CALLER_WINS = "caller_wins"
|
|
71
|
+
CONFIG_WINS = "config_wins"
|
|
72
|
+
RAISE = "raise"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RetryMode(StrEnum):
|
|
76
|
+
OWNED = "owned"
|
|
77
|
+
DELEGATED = "delegated"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
DEFAULT_RETRYABLE_KINDS: Final = frozenset(
|
|
81
|
+
{
|
|
82
|
+
FailureKind.CONNECT_TIMEOUT,
|
|
83
|
+
FailureKind.CONNECT_ERROR,
|
|
84
|
+
FailureKind.DNS_ERROR,
|
|
85
|
+
FailureKind.POOL_TIMEOUT,
|
|
86
|
+
FailureKind.READ_TIMEOUT,
|
|
87
|
+
FailureKind.DISCONNECTED,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
DEFAULT_RETRYABLE_STATUS: Final = frozenset({429, 502, 503, 504})
|
|
92
|
+
|
|
93
|
+
DEFAULT_TRIP_KINDS: Final = frozenset(
|
|
94
|
+
{
|
|
95
|
+
FailureKind.CONNECT_TIMEOUT,
|
|
96
|
+
FailureKind.READ_TIMEOUT,
|
|
97
|
+
FailureKind.WRITE_TIMEOUT,
|
|
98
|
+
FailureKind.POOL_TIMEOUT,
|
|
99
|
+
FailureKind.TOTAL_TIMEOUT,
|
|
100
|
+
FailureKind.CONNECT_ERROR,
|
|
101
|
+
FailureKind.DNS_ERROR,
|
|
102
|
+
FailureKind.TLS_ERROR,
|
|
103
|
+
FailureKind.DISCONNECTED,
|
|
104
|
+
FailureKind.PROTOCOL_ERROR,
|
|
105
|
+
FailureKind.STATUS,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
DEFAULT_SENSITIVE_HEADERS: Final = frozenset(
|
|
110
|
+
{
|
|
111
|
+
"authorization",
|
|
112
|
+
"proxy-authorization",
|
|
113
|
+
"cookie",
|
|
114
|
+
"set-cookie",
|
|
115
|
+
"x-api-key",
|
|
116
|
+
"x-auth-token",
|
|
117
|
+
"api-key",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
DEFAULT_SENSITIVE_QUERY_PARAMS: Final = frozenset(
|
|
122
|
+
{
|
|
123
|
+
"access_token",
|
|
124
|
+
"api_key",
|
|
125
|
+
"apikey",
|
|
126
|
+
"client_secret",
|
|
127
|
+
"code",
|
|
128
|
+
"password",
|
|
129
|
+
"refresh_token",
|
|
130
|
+
"secret",
|
|
131
|
+
"token",
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _require_positive(name: str, value: object) -> None:
|
|
137
|
+
if isinstance(value, _Unset) or value is None:
|
|
138
|
+
return
|
|
139
|
+
if isinstance(value, (int, float)) and value <= 0:
|
|
140
|
+
raise ValueError(f"{name} must be positive, got {value!r}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _coerce_enum[EnumT: StrEnum](config: object, field: str, enum: type[EnumT]) -> None:
|
|
144
|
+
"""Turn a plain string into the real enum member, in place.
|
|
145
|
+
|
|
146
|
+
These fields are ``StrEnum``, so ``"delegated"`` looks like it works - but
|
|
147
|
+
the engine compares members with ``is``, and a bare string is never the same
|
|
148
|
+
object as a member. Config loaded from YAML or the environment would then
|
|
149
|
+
match no branch at all and silently take the fallback. Coercing here also
|
|
150
|
+
turns a typo into a ValueError at construction instead of a knob that
|
|
151
|
+
quietly does nothing.
|
|
152
|
+
"""
|
|
153
|
+
value = getattr(config, field)
|
|
154
|
+
if type(value) is enum:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
member = enum(value)
|
|
158
|
+
except ValueError:
|
|
159
|
+
allowed = ", ".join(repr(member.value) for member in enum)
|
|
160
|
+
raise ValueError(f"{field} must be one of {allowed}, got {value!r}") from None
|
|
161
|
+
object.__setattr__(config, field, member) # frozen dataclass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, slots=True)
|
|
165
|
+
class TimeoutConfig:
|
|
166
|
+
"""Timeout budget of a logical call.
|
|
167
|
+
|
|
168
|
+
``total`` is guaranteed everywhere: the engine enforces it with a monotonic
|
|
169
|
+
deadline shared by all attempts, backoff sleeps and redirect hops.
|
|
170
|
+
Phase knobs left ``UNSET`` defer to the adapter's native defaults.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
total: float | None = 30.0
|
|
174
|
+
attempt: Maybe[float | None] = UNSET
|
|
175
|
+
connect: Maybe[float | None] = 5.0
|
|
176
|
+
read: Maybe[float | None] = UNSET
|
|
177
|
+
write: Maybe[float | None] = UNSET
|
|
178
|
+
pool_acquire: Maybe[float | None] = UNSET
|
|
179
|
+
|
|
180
|
+
def __post_init__(self) -> None:
|
|
181
|
+
for name in ("total", "attempt", "connect", "read", "write", "pool_acquire"):
|
|
182
|
+
_require_positive(f"timeout.{name}", getattr(self, name))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class PoolConfig:
|
|
187
|
+
"""Connection pool shape; unset knobs defer to native defaults."""
|
|
188
|
+
|
|
189
|
+
max_connections: Maybe[int | None] = 100
|
|
190
|
+
max_keepalive: Maybe[int | None] = 20
|
|
191
|
+
keepalive_expiry: Maybe[float | None] = 30.0
|
|
192
|
+
max_connections_per_host: Maybe[int | None] = UNSET
|
|
193
|
+
http2: Maybe[bool] = UNSET
|
|
194
|
+
|
|
195
|
+
def __post_init__(self) -> None:
|
|
196
|
+
for name in ("max_connections", "max_keepalive", "keepalive_expiry", "max_connections_per_host"):
|
|
197
|
+
_require_positive(f"pool.{name}", getattr(self, name))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True, slots=True)
|
|
201
|
+
class RetryConfig:
|
|
202
|
+
"""Owned retry policy; the engine runs the loop, adapters only send."""
|
|
203
|
+
|
|
204
|
+
max_attempts: int = 3
|
|
205
|
+
initial_backoff: float = 0.1
|
|
206
|
+
max_backoff: float = 10.0
|
|
207
|
+
multiplier: float = 2.0
|
|
208
|
+
jitter: float = 0.2
|
|
209
|
+
retryable_kinds: frozenset[FailureKind] = DEFAULT_RETRYABLE_KINDS
|
|
210
|
+
retryable_status: frozenset[int] = DEFAULT_RETRYABLE_STATUS
|
|
211
|
+
methods: frozenset[str] = IDEMPOTENT_METHODS
|
|
212
|
+
respect_retry_after: bool = True
|
|
213
|
+
retry_after_max: float = 60.0
|
|
214
|
+
budget_ratio: float | None = 0.1
|
|
215
|
+
require_replayable_body: bool = True
|
|
216
|
+
mode: RetryMode = RetryMode.OWNED
|
|
217
|
+
|
|
218
|
+
def __post_init__(self) -> None:
|
|
219
|
+
_coerce_enum(self, "mode", RetryMode)
|
|
220
|
+
if self.max_attempts < 1:
|
|
221
|
+
raise ValueError(f"retry.max_attempts must be >= 1, got {self.max_attempts}")
|
|
222
|
+
_require_positive("retry.initial_backoff", self.initial_backoff)
|
|
223
|
+
_require_positive("retry.max_backoff", self.max_backoff)
|
|
224
|
+
if self.multiplier < 1.0:
|
|
225
|
+
raise ValueError(f"retry.multiplier must be >= 1, got {self.multiplier}")
|
|
226
|
+
if not 0.0 <= self.jitter <= 1.0:
|
|
227
|
+
raise ValueError(f"retry.jitter must be within [0, 1], got {self.jitter}")
|
|
228
|
+
if self.budget_ratio is not None and not 0.0 < self.budget_ratio <= 1.0:
|
|
229
|
+
raise ValueError(f"retry.budget_ratio must be within (0, 1], got {self.budget_ratio}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True)
|
|
233
|
+
class CircuitBreakerConfig:
|
|
234
|
+
"""Circuit breaker keyed per CircuitKey; one signal per logical call.
|
|
235
|
+
|
|
236
|
+
A ``STATUS`` outcome trips the breaker only for 5xx responses.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
fail_threshold: int = 5
|
|
240
|
+
recovery_timeout: float = 60.0
|
|
241
|
+
half_open_max_calls: int = 1
|
|
242
|
+
max_keys: int = 512
|
|
243
|
+
key: CircuitKey = CircuitKey.ORIGIN
|
|
244
|
+
trip_kinds: frozenset[FailureKind] = DEFAULT_TRIP_KINDS
|
|
245
|
+
|
|
246
|
+
def __post_init__(self) -> None:
|
|
247
|
+
_coerce_enum(self, "key", CircuitKey)
|
|
248
|
+
if self.fail_threshold < 1:
|
|
249
|
+
raise ValueError(f"circuit_breaker.fail_threshold must be >= 1, got {self.fail_threshold}")
|
|
250
|
+
_require_positive("circuit_breaker.recovery_timeout", self.recovery_timeout)
|
|
251
|
+
if self.half_open_max_calls < 1:
|
|
252
|
+
raise ValueError(f"circuit_breaker.half_open_max_calls must be >= 1, got {self.half_open_max_calls}")
|
|
253
|
+
if self.max_keys < 1:
|
|
254
|
+
raise ValueError(f"circuit_breaker.max_keys must be >= 1, got {self.max_keys}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# Module attribute captured before the dataclass: the field named ``logging``
|
|
258
|
+
# shadows the module inside the class body.
|
|
259
|
+
_INFO_LEVEL: Final = logging.INFO
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(frozen=True, slots=True)
|
|
263
|
+
class ObservabilityConfig:
|
|
264
|
+
"""Which telemetry channels are active; only knobs that actually work exist here."""
|
|
265
|
+
|
|
266
|
+
logging: bool = True
|
|
267
|
+
metrics: bool = True
|
|
268
|
+
tracing: bool = True
|
|
269
|
+
success_log_level: int = _INFO_LEVEL
|
|
270
|
+
sensitive_headers: frozenset[str] = DEFAULT_SENSITIVE_HEADERS
|
|
271
|
+
sensitive_query_params: frozenset[str] = DEFAULT_SENSITIVE_QUERY_PARAMS
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True, slots=True)
|
|
275
|
+
class TlsConfig:
|
|
276
|
+
verify: bool = True
|
|
277
|
+
ca_bundle: str | None = None
|
|
278
|
+
cert: str | tuple[str, str] | tuple[str, str, str] | None = None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True, slots=True)
|
|
282
|
+
class ProxyConfig:
|
|
283
|
+
"""Explicit proxy or environment-driven proxies (mutually exclusive)."""
|
|
284
|
+
|
|
285
|
+
url: str | None = None
|
|
286
|
+
from_env: bool = False
|
|
287
|
+
|
|
288
|
+
def __post_init__(self) -> None:
|
|
289
|
+
if self.url is not None and self.from_env:
|
|
290
|
+
raise ValueError("proxy.url and proxy.from_env are mutually exclusive")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True, slots=True)
|
|
294
|
+
class NativeOptions:
|
|
295
|
+
"""Raw passthrough to the native client, grouped by adapter-declared slots.
|
|
296
|
+
|
|
297
|
+
Validated at build time: unknown slots, reserved keys, typos and collisions
|
|
298
|
+
with explicitly set config fields are all errors, not silent behavior.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
slots: Mapping[str, Mapping[str, object]] = field(default_factory=dict)
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def of(cls, **slots: Mapping[str, object]) -> NativeOptions:
|
|
305
|
+
return cls(slots={name: dict(values) for name, values in slots.items()})
|
|
306
|
+
|
|
307
|
+
def for_slot(self, name: str) -> dict[str, object]:
|
|
308
|
+
return dict(self.slots.get(name, {}))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True, slots=True)
|
|
312
|
+
class ClientConfig:
|
|
313
|
+
"""The whole client, described once, readable by every adapter."""
|
|
314
|
+
|
|
315
|
+
service_name: str
|
|
316
|
+
base_url: str | None = None
|
|
317
|
+
timeout: TimeoutConfig = field(default_factory=TimeoutConfig)
|
|
318
|
+
pool: PoolConfig = field(default_factory=PoolConfig)
|
|
319
|
+
retry: RetryConfig | None = field(default_factory=RetryConfig)
|
|
320
|
+
circuit_breaker: CircuitBreakerConfig | None = field(default_factory=CircuitBreakerConfig)
|
|
321
|
+
tls: TlsConfig = field(default_factory=TlsConfig)
|
|
322
|
+
proxy: ProxyConfig | None = None
|
|
323
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
324
|
+
redirects: RedirectMode = RedirectMode.OWNED
|
|
325
|
+
max_redirects: int = 5
|
|
326
|
+
caller_override: CallerOverride = CallerOverride.CALLER_WINS
|
|
327
|
+
deadline_header: str | None = None
|
|
328
|
+
observability: ObservabilityConfig = field(default_factory=ObservabilityConfig)
|
|
329
|
+
native: NativeOptions = field(default_factory=NativeOptions)
|
|
330
|
+
on_unsupported: UnsupportedPolicy = UnsupportedPolicy.WARN
|
|
331
|
+
|
|
332
|
+
def __post_init__(self) -> None:
|
|
333
|
+
_coerce_enum(self, "redirects", RedirectMode)
|
|
334
|
+
_coerce_enum(self, "caller_override", CallerOverride)
|
|
335
|
+
_coerce_enum(self, "on_unsupported", UnsupportedPolicy)
|
|
336
|
+
if not self.service_name or not self.service_name.strip():
|
|
337
|
+
raise ValueError("service_name must be a non-empty string")
|
|
338
|
+
if self.base_url is not None and not self.base_url.startswith(("http://", "https://")):
|
|
339
|
+
raise ValueError(f"base_url must start with http:// or https://, got {self.base_url!r}")
|
|
340
|
+
if self.max_redirects < 0:
|
|
341
|
+
raise ValueError(f"max_redirects must be >= 0, got {self.max_redirects}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
__all__ = [
|
|
345
|
+
"DEFAULT_RETRYABLE_KINDS",
|
|
346
|
+
"DEFAULT_RETRYABLE_STATUS",
|
|
347
|
+
"DEFAULT_SENSITIVE_HEADERS",
|
|
348
|
+
"DEFAULT_SENSITIVE_QUERY_PARAMS",
|
|
349
|
+
"DEFAULT_TRIP_KINDS",
|
|
350
|
+
"UNSET",
|
|
351
|
+
"CallerOverride",
|
|
352
|
+
"CircuitBreakerConfig",
|
|
353
|
+
"ClientConfig",
|
|
354
|
+
"Maybe",
|
|
355
|
+
"NativeOptions",
|
|
356
|
+
"ObservabilityConfig",
|
|
357
|
+
"PoolConfig",
|
|
358
|
+
"ProxyConfig",
|
|
359
|
+
"RedirectMode",
|
|
360
|
+
"RetryConfig",
|
|
361
|
+
"RetryMode",
|
|
362
|
+
"TimeoutConfig",
|
|
363
|
+
"TlsConfig",
|
|
364
|
+
"UnsupportedPolicy",
|
|
365
|
+
"is_set",
|
|
366
|
+
"resolve",
|
|
367
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The entire contract surface of the kernel: pure protocols, no SDKs."""
|
|
2
|
+
|
|
3
|
+
from .adapter import AdapterDeps as AdapterDeps
|
|
4
|
+
from .adapter import ClientAdapter as ClientAdapter
|
|
5
|
+
from .context import DeadlineSource as DeadlineSource
|
|
6
|
+
from .context import HeaderProvider as HeaderProvider
|
|
7
|
+
from .message import AsyncNormalizer as AsyncNormalizer
|
|
8
|
+
from .message import RequestView as RequestView
|
|
9
|
+
from .message import ResponseView as ResponseView
|
|
10
|
+
from .message import SyncNormalizer as SyncNormalizer
|
|
11
|
+
from .observability import ClientMetricsProtocol as ClientMetricsProtocol
|
|
12
|
+
from .observability import SpanProtocol as SpanProtocol
|
|
13
|
+
from .observability import TracerProtocol as TracerProtocol
|
|
14
|
+
from .settings import CircuitBreakerSettingsProtocol as CircuitBreakerSettingsProtocol
|
|
15
|
+
from .settings import ClientSettingsProtocol as ClientSettingsProtocol
|
|
16
|
+
from .settings import RetrySettingsProtocol as RetrySettingsProtocol
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AdapterDeps",
|
|
20
|
+
"AsyncNormalizer",
|
|
21
|
+
"CircuitBreakerSettingsProtocol",
|
|
22
|
+
"ClientAdapter",
|
|
23
|
+
"ClientMetricsProtocol",
|
|
24
|
+
"ClientSettingsProtocol",
|
|
25
|
+
"DeadlineSource",
|
|
26
|
+
"HeaderProvider",
|
|
27
|
+
"RequestView",
|
|
28
|
+
"ResponseView",
|
|
29
|
+
"RetrySettingsProtocol",
|
|
30
|
+
"SpanProtocol",
|
|
31
|
+
"SyncNormalizer",
|
|
32
|
+
"TracerProtocol",
|
|
33
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Adapter contract and the dependency bundle handed to builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from ..capabilities import AdapterCapabilities
|
|
10
|
+
from ..config import ClientConfig
|
|
11
|
+
from .context import DeadlineSource, HeaderProvider
|
|
12
|
+
from .observability import ClientMetricsProtocol, TracerProtocol
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..plan import ClientHandle, ClientRuntime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class AdapterDeps:
|
|
20
|
+
"""Everything a service may inject; every field has a working default.
|
|
21
|
+
|
|
22
|
+
``runtime`` deserves APP scope in DI: circuits, retry budgets and semaphores
|
|
23
|
+
must outlive REQUEST-scoped clients or they are dead code.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
metrics: ClientMetricsProtocol | None = None
|
|
27
|
+
tracer: TracerProtocol | None = None
|
|
28
|
+
header_providers: tuple[HeaderProvider, ...] = ()
|
|
29
|
+
deadline_source: DeadlineSource | None = None
|
|
30
|
+
runtime: ClientRuntime | None = None
|
|
31
|
+
clock: Callable[[], float] | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class ClientAdapter(Protocol):
|
|
36
|
+
"""One HTTP client framework, wired under its public API.
|
|
37
|
+
|
|
38
|
+
``native_slots`` names the slots accepted in ``NativeOptions``;
|
|
39
|
+
``reserved_keys`` maps slot -> keys the kit owns (with a hint how to get the
|
|
40
|
+
same effect legally); ``allowed_keys`` maps slot -> explicit allowlist or
|
|
41
|
+
``None`` to validate against the constructor signature.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
capabilities: AdapterCapabilities
|
|
46
|
+
native_slots: frozenset[str]
|
|
47
|
+
reserved_keys: Mapping[str, Mapping[str, str]]
|
|
48
|
+
allowed_keys: Mapping[str, frozenset[str] | None]
|
|
49
|
+
|
|
50
|
+
def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]: ...
|
|
51
|
+
|
|
52
|
+
def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_DEFAULT_DEPS = AdapterDeps()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_deps() -> AdapterDeps:
|
|
59
|
+
return _DEFAULT_DEPS
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["AdapterDeps", "ClientAdapter", "default_deps"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Ambient-context seams: header propagation and deadline budgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class HeaderProvider(Protocol):
|
|
11
|
+
"""Supplies outgoing headers from ambient context (request-id, trace-id, ...).
|
|
12
|
+
|
|
13
|
+
Injected once per logical call, before any logging; existing header values
|
|
14
|
+
set by the caller are never overwritten.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __call__(self) -> Mapping[str, str]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class DeadlineSource(Protocol):
|
|
22
|
+
"""Remaining time budget of the surrounding operation, in seconds.
|
|
23
|
+
|
|
24
|
+
``None`` means no ambient budget. The engine intersects this with the
|
|
25
|
+
configured total timeout and any per-call override.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def remaining(self) -> float | None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = ["DeadlineSource", "HeaderProvider"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Views over native request/response objects plus the normalizer contracts.
|
|
2
|
+
|
|
3
|
+
The engine talks to these views only; it never reads ``.native``. A normalizer
|
|
4
|
+
is the WHOLE per-client contract: wrap, classify, freeze/rewind/discard and
|
|
5
|
+
stream wrapping. It comes in async and sync flavors because freeze/rewind/
|
|
6
|
+
discard genuinely do I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, MutableMapping
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from ..model import ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class RequestView(Protocol):
|
|
19
|
+
"""Mutable view over a native in-flight request."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def native(self) -> Any: ...
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def info(self) -> RequestInfo: ...
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def headers(self) -> MutableMapping[str, str]: ...
|
|
29
|
+
|
|
30
|
+
def caller_timeouts(self) -> ResolvedTimeouts | None:
|
|
31
|
+
"""Per-call timeouts set by the CALLER, or None when nothing was set."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
|
|
35
|
+
"""Install the engine's per-attempt timeout plan into the native request."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
|
|
39
|
+
"""Point the request at a new URL before sending (owned redirects, balancing)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class ResponseView(Protocol):
|
|
45
|
+
"""Read-only view over a native response."""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def native(self) -> Any: ...
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def status_code(self) -> int: ...
|
|
52
|
+
|
|
53
|
+
def header(self, name: str) -> str | None: ...
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def location(self) -> str | None: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AsyncNormalizer(Protocol):
|
|
60
|
+
"""Async adapter contract; roughly 60-120 lines per client."""
|
|
61
|
+
|
|
62
|
+
def wrap_request(self, native: Any) -> RequestView: ...
|
|
63
|
+
|
|
64
|
+
def wrap_response(self, native: Any) -> ResponseView: ...
|
|
65
|
+
|
|
66
|
+
def classify_error(self, exc: BaseException) -> FailureKind: ...
|
|
67
|
+
|
|
68
|
+
def classify_response(self, response: ResponseView) -> Outcome:
|
|
69
|
+
"""Default status classification lives in the engine; override only where
|
|
70
|
+
failures arrive NOT as exceptions."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
async def freeze(self, request: RequestView) -> bool:
|
|
74
|
+
"""Make the body replayable. False means a repeat is forbidden."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
async def rewind(self, request: RequestView) -> None: ...
|
|
78
|
+
|
|
79
|
+
async def discard(self, response: ResponseView) -> None:
|
|
80
|
+
"""MANDATORY before a repeat - otherwise the connection never returns to the pool."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None:
|
|
84
|
+
"""boundary=full: wrap the body stream so read duration and errors reach
|
|
85
|
+
telemetry. No-op where there is nothing to wrap."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
def conn_metrics(self, response: ResponseView) -> ConnMetrics | None: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SyncNormalizer(Protocol):
|
|
92
|
+
"""Sync twin of AsyncNormalizer; freeze/rewind/discard are blocking calls."""
|
|
93
|
+
|
|
94
|
+
def wrap_request(self, native: Any) -> RequestView: ...
|
|
95
|
+
|
|
96
|
+
def wrap_response(self, native: Any) -> ResponseView: ...
|
|
97
|
+
|
|
98
|
+
def classify_error(self, exc: BaseException) -> FailureKind: ...
|
|
99
|
+
|
|
100
|
+
def classify_response(self, response: ResponseView) -> Outcome: ...
|
|
101
|
+
|
|
102
|
+
def freeze(self, request: RequestView) -> bool: ...
|
|
103
|
+
|
|
104
|
+
def rewind(self, request: RequestView) -> None: ...
|
|
105
|
+
|
|
106
|
+
def discard(self, response: ResponseView) -> None: ...
|
|
107
|
+
|
|
108
|
+
def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None: ...
|
|
109
|
+
|
|
110
|
+
def conn_metrics(self, response: ResponseView) -> ConnMetrics | None: ...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = [
|
|
114
|
+
"AsyncNormalizer",
|
|
115
|
+
"RequestView",
|
|
116
|
+
"ResponseView",
|
|
117
|
+
"SyncNormalizer",
|
|
118
|
+
]
|