grpc-client-kit 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.
- grpc_client_kit/__init__.py +166 -0
- grpc_client_kit/__version__.py +1 -0
- grpc_client_kit/balancers.py +362 -0
- grpc_client_kit/channel.py +511 -0
- grpc_client_kit/client.py +236 -0
- grpc_client_kit/config.py +185 -0
- grpc_client_kit/deadline.py +122 -0
- grpc_client_kit/errors.py +51 -0
- grpc_client_kit/factory.py +482 -0
- grpc_client_kit/health.py +408 -0
- grpc_client_kit/interceptors/__init__.py +523 -0
- grpc_client_kit/interceptors/base.py +476 -0
- grpc_client_kit/interceptors/circuit_breaker.py +408 -0
- grpc_client_kit/interceptors/client_logging.py +329 -0
- grpc_client_kit/interceptors/context.py +135 -0
- grpc_client_kit/interceptors/deadline.py +154 -0
- grpc_client_kit/interceptors/metrics.py +219 -0
- grpc_client_kit/interceptors/outlier.py +78 -0
- grpc_client_kit/interceptors/retry.py +501 -0
- grpc_client_kit/interceptors/timeout.py +141 -0
- grpc_client_kit/interceptors/tracing.py +307 -0
- grpc_client_kit/interceptors/wait_for_ready.py +139 -0
- grpc_client_kit/protocols.py +449 -0
- grpc_client_kit/py.typed +0 -0
- grpc_client_kit/utils.py +99 -0
- grpc_client_kit/validation.py +232 -0
- grpc_client_kit-0.1.0.dist-info/METADATA +560 -0
- grpc_client_kit-0.1.0.dist-info/RECORD +30 -0
- grpc_client_kit-0.1.0.dist-info/WHEEL +4 -0
- grpc_client_kit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import grpc.aio
|
|
7
|
+
|
|
8
|
+
from .balancers import LoadBalancer, LoadBalancerConfig, LoadBalancingStrategy, create_balancer
|
|
9
|
+
from .channel import ChannelPool
|
|
10
|
+
from .client import GrpcClient
|
|
11
|
+
from .config import GrpcClientConfig
|
|
12
|
+
from .interceptors import (
|
|
13
|
+
CircuitBreakerConfig,
|
|
14
|
+
DeadlineBudgetConfig,
|
|
15
|
+
InterceptorChainBuilder,
|
|
16
|
+
ObservabilityConfig,
|
|
17
|
+
RetryConfig,
|
|
18
|
+
TimeoutConfig,
|
|
19
|
+
WaitForReadyConfig,
|
|
20
|
+
)
|
|
21
|
+
from .protocols import (
|
|
22
|
+
ChannelProviderProtocol,
|
|
23
|
+
CircuitBreakerSettingsProtocol,
|
|
24
|
+
GrpcClientMetricsProtocol,
|
|
25
|
+
GrpcClientSettingsProtocol,
|
|
26
|
+
RetryMetricsProtocol,
|
|
27
|
+
RetrySettingsProtocol,
|
|
28
|
+
TimeoutSettingsProtocol,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from .health import HealthChecker
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_MISSING_MESSAGE = "Install grpc-client-kit[health] (grpcio-health-checking) to use gRPC health checking"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_settings(settings: object | None) -> None:
|
|
40
|
+
"""Refuse a settings object that cannot satisfy the factory, while the mistake is visible.
|
|
41
|
+
|
|
42
|
+
Chains are built lazily, per target, so a settings object missing a required field would
|
|
43
|
+
otherwise be accepted here and explode with a bare ``AttributeError`` on the first RPC.
|
|
44
|
+
Optional blocks stay optional — this checks the required protocol surface only.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
settings: The object handed to the factory, or None.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
TypeError: If the object does not satisfy `GrpcClientSettingsProtocol`, naming exactly
|
|
51
|
+
the fields it is missing.
|
|
52
|
+
"""
|
|
53
|
+
if settings is None or isinstance(settings, GrpcClientSettingsProtocol):
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
required = [name for name in dir(GrpcClientSettingsProtocol) if not name.startswith("_")]
|
|
57
|
+
missing = sorted(name for name in required if not hasattr(settings, name))
|
|
58
|
+
raise TypeError(
|
|
59
|
+
f"settings object {type(settings).__name__!r} does not satisfy GrpcClientSettingsProtocol: "
|
|
60
|
+
f"missing {', '.join(missing) if missing else 'field types differ from the protocol'}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_health_checker() -> type[HealthChecker]:
|
|
65
|
+
"""Import the health checker on demand.
|
|
66
|
+
|
|
67
|
+
Health checking is an optional extra, so ``grpc_health`` must stay out of import time: the
|
|
68
|
+
package has to install and import without it.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The HealthChecker class.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ImportError: If the [health] extra is not installed.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
from .health import HealthChecker # noqa: PLC0415
|
|
78
|
+
except ImportError as exc:
|
|
79
|
+
raise ImportError(_MISSING_MESSAGE) from exc
|
|
80
|
+
|
|
81
|
+
return HealthChecker
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class GrpcClientFactory:
|
|
85
|
+
"""High-level factory for creating configured GrpcClient instances.
|
|
86
|
+
|
|
87
|
+
Encapsulates mapping from settings objects to kit-specific configuration objects.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
settings: GrpcClientSettingsProtocol | None = None,
|
|
93
|
+
pool: ChannelProviderProtocol | None = None,
|
|
94
|
+
shutdown_grace: float | None = 5.0,
|
|
95
|
+
ready_timeout: float | None = 10.0,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Initialize the factory.
|
|
98
|
+
|
|
99
|
+
Ownership Semantics:
|
|
100
|
+
If `pool` is provided, this factory uses it but does NOT own it. Closing the factory
|
|
101
|
+
will not close the provided pool.
|
|
102
|
+
If `pool` is NOT provided, the factory creates and OWNS its own `ChannelPool`.
|
|
103
|
+
Closing the factory will close this internal pool.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
settings: Base settings object (optional)
|
|
107
|
+
pool: Shared channel provider (if not provided, creates a ChannelPool based on settings)
|
|
108
|
+
shutdown_grace: Seconds in-flight RPCs get to finish when the ``async with`` block is
|
|
109
|
+
left. ``None`` cancels them immediately — a deliberate choice, not the default:
|
|
110
|
+
a k8s SIGTERM lands here, and cutting every in-flight dependency call short turns
|
|
111
|
+
a rolling deploy into an error spike.
|
|
112
|
+
ready_timeout: Seconds ``__aenter__`` waits for the first health check pass. Until
|
|
113
|
+
that pass every target reads unhealthy, so entering the context without waiting
|
|
114
|
+
would make the first call of every freshly started pod fail deterministically.
|
|
115
|
+
``None`` waits indefinitely; only relevant when health checking is configured.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
TypeError: If `settings` does not satisfy `GrpcClientSettingsProtocol`. Validated
|
|
119
|
+
eagerly: a missing required field otherwise surfaces as a bare ``AttributeError``
|
|
120
|
+
on the first RPC, in production, far from the mistake.
|
|
121
|
+
ImportError: If health checking is configured but the [health] extra is missing.
|
|
122
|
+
"""
|
|
123
|
+
_validate_settings(settings)
|
|
124
|
+
self._settings = settings
|
|
125
|
+
self._shutdown_grace = shutdown_grace
|
|
126
|
+
self._ready_timeout = ready_timeout
|
|
127
|
+
self._owns_pool = False
|
|
128
|
+
self._health_checker: HealthChecker | None = None
|
|
129
|
+
# Chains cached per (service_name, target): recreating a client for the same stub must
|
|
130
|
+
# reuse the same interceptor instances, or every create_client would mint a fresh channel
|
|
131
|
+
# identity and a per-request client pattern would open a connection per request.
|
|
132
|
+
self._chains: dict[tuple[str, str], list[grpc.aio.ClientInterceptor]] = {}
|
|
133
|
+
|
|
134
|
+
if pool:
|
|
135
|
+
self._pool = pool
|
|
136
|
+
else:
|
|
137
|
+
self._owns_pool = True
|
|
138
|
+
metrics = getattr(settings, "metrics_registry", None) if settings else None
|
|
139
|
+
|
|
140
|
+
if settings and settings.pool:
|
|
141
|
+
self._pool = ChannelPool(
|
|
142
|
+
max_channels_per_target=settings.pool.max_channels_per_target,
|
|
143
|
+
idle_timeout=settings.pool.idle_timeout,
|
|
144
|
+
metrics=metrics,
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
self._pool = ChannelPool(metrics=metrics)
|
|
148
|
+
|
|
149
|
+
# Build and start HealthChecker if configured and we have targets
|
|
150
|
+
if settings and settings.health_checker and settings.targets:
|
|
151
|
+
health_checker_class = _load_health_checker()
|
|
152
|
+
self._health_checker = health_checker_class(
|
|
153
|
+
check_interval=settings.health_checker.check_interval,
|
|
154
|
+
timeout=settings.health_checker.timeout,
|
|
155
|
+
service=getattr(settings.health_checker, "service", ""),
|
|
156
|
+
insecure=settings.insecure,
|
|
157
|
+
credentials=getattr(settings, "credentials", None),
|
|
158
|
+
# Same options and compression as the application channels, so the health probes
|
|
159
|
+
# negotiate HTTP/2 the same way the real traffic does.
|
|
160
|
+
options=getattr(settings, "options", None),
|
|
161
|
+
compression=getattr(settings, "compression", None),
|
|
162
|
+
# Pass pool if it supports updating health status
|
|
163
|
+
pool=self._pool if isinstance(self._pool, ChannelPool) else None,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def circuit_breaker_states(self) -> dict[str, dict[str, Any]]:
|
|
167
|
+
"""Snapshot the circuit breakers of every chain this factory has built.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Mapping of ``"service -> target"`` to that chain's breaker snapshot (method to
|
|
171
|
+
status). Chains without a breaker are absent.
|
|
172
|
+
"""
|
|
173
|
+
from .interceptors.base import logical_interceptor # noqa: PLC0415 - avoids import cycle at module load
|
|
174
|
+
from .interceptors.circuit_breaker import AsyncCircuitBreakerInterceptor # noqa: PLC0415
|
|
175
|
+
|
|
176
|
+
snapshot: dict[str, dict[str, Any]] = {}
|
|
177
|
+
for (service_name, target), chain in self._chains.items():
|
|
178
|
+
for entry in chain:
|
|
179
|
+
owner = logical_interceptor(entry)
|
|
180
|
+
if isinstance(owner, AsyncCircuitBreakerInterceptor):
|
|
181
|
+
snapshot[f"{service_name} -> {target}"] = dict(await owner.get_states())
|
|
182
|
+
break
|
|
183
|
+
return snapshot
|
|
184
|
+
|
|
185
|
+
async def close(self, grace: float | None = None) -> None:
|
|
186
|
+
"""Close the underlying pool and stop the health checker.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
grace: Optional time in seconds to allow active RPCs to complete.
|
|
190
|
+
"""
|
|
191
|
+
if self._health_checker:
|
|
192
|
+
await self._health_checker.stop()
|
|
193
|
+
|
|
194
|
+
if self._owns_pool:
|
|
195
|
+
await self._pool.close_all(grace=grace)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def health_checker(self) -> HealthChecker | None:
|
|
199
|
+
"""The checker this factory built from settings, or None when none is configured."""
|
|
200
|
+
return self._health_checker
|
|
201
|
+
|
|
202
|
+
async def wait_until_ready(self, timeout: float | None = None) -> bool:
|
|
203
|
+
"""Wait until the first health check pass has classified every target.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
timeout: Maximum seconds to wait. None waits indefinitely.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True once the first pass completed — and trivially when no checker is configured,
|
|
210
|
+
since there is then nothing to wait for. False if the wait timed out or the checker
|
|
211
|
+
is not running.
|
|
212
|
+
"""
|
|
213
|
+
if self._health_checker is None:
|
|
214
|
+
return True
|
|
215
|
+
return await self._health_checker.wait_until_ready(timeout=timeout)
|
|
216
|
+
|
|
217
|
+
async def __aenter__(self) -> GrpcClientFactory:
|
|
218
|
+
"""Start the health checker and wait for its first verdicts.
|
|
219
|
+
|
|
220
|
+
Waiting is the point: until the first pass every target reads unhealthy by design, so a
|
|
221
|
+
factory that returned before it would hand out clients whose very first call fails with
|
|
222
|
+
`NoHealthyTargetsError` on every fresh start — a deterministic error spike per deploy.
|
|
223
|
+
"""
|
|
224
|
+
if self._health_checker and self._settings and self._settings.targets:
|
|
225
|
+
await self._health_checker.start(self._settings.targets)
|
|
226
|
+
await self._health_checker.wait_until_ready(timeout=self._ready_timeout)
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
230
|
+
"""Async context manager exit. Calls close() with the configured shutdown grace."""
|
|
231
|
+
await self.close(grace=self._shutdown_grace)
|
|
232
|
+
|
|
233
|
+
def create_client[T](
|
|
234
|
+
self,
|
|
235
|
+
stub_class: type[T],
|
|
236
|
+
target: str | None = None,
|
|
237
|
+
service_name: str | None = None,
|
|
238
|
+
metrics: GrpcClientMetricsProtocol | None = None,
|
|
239
|
+
interceptors: list[grpc.aio.ClientInterceptor] | None = None,
|
|
240
|
+
) -> GrpcClient[T]:
|
|
241
|
+
"""Create a fully configured gRPC client instance.
|
|
242
|
+
|
|
243
|
+
This method applies resilience (retries, timeouts, circuit breakers)
|
|
244
|
+
and observability (metrics, logging, tracing) based on factory settings.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
stub_class: The gRPC stub class to instantiate.
|
|
248
|
+
target: Optional target override. If not provided, uses target from settings.
|
|
249
|
+
service_name: Name of the service for observability. Defaults to stub class name.
|
|
250
|
+
metrics: Optional metrics registry, overriding `settings.metrics_registry`.
|
|
251
|
+
interceptors: Optional list of additional custom interceptors. These instances are
|
|
252
|
+
shared by every target, unlike the chain the factory builds around them.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
A configured GrpcClient instance.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
ValueError: If neither a target nor a list of targets is configured.
|
|
259
|
+
"""
|
|
260
|
+
config = self._build_config(target)
|
|
261
|
+
balancer = self._build_balancer()
|
|
262
|
+
|
|
263
|
+
# Validation: either target or balancer must be available
|
|
264
|
+
if not config.target and not balancer:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"No target configured. Provide either 'target' in settings/overrides or 'targets' for load balancing."
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Lifecycle warning
|
|
270
|
+
if self._health_checker and not self._health_checker.is_running:
|
|
271
|
+
logger.warning(
|
|
272
|
+
"GrpcClientFactory used without 'async with'. "
|
|
273
|
+
"Health checks are NOT running. Traffic may be sent to unhealthy targets."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
actual_service_name = service_name or stub_class.__name__
|
|
277
|
+
metrics_registry = self._resolve_metrics_registry(metrics)
|
|
278
|
+
custom_interceptors = list(interceptors) if interceptors else []
|
|
279
|
+
|
|
280
|
+
def build_chain(chain_target: str) -> list[grpc.aio.ClientInterceptor]:
|
|
281
|
+
"""Build a chain dedicated to one target so its stateful interceptors stay isolated.
|
|
282
|
+
|
|
283
|
+
Chains are cached per (service_name, target): recreating a client for the same stub —
|
|
284
|
+
a per-request DI scope, say — must land on the same interceptor instances and thereby
|
|
285
|
+
the same pooled channel, not open a new connection per client. Custom interceptors are
|
|
286
|
+
caller-owned instances the factory cannot prove reusable, so chains around them stay
|
|
287
|
+
per-client.
|
|
288
|
+
"""
|
|
289
|
+
cache_key = (actual_service_name, chain_target)
|
|
290
|
+
if not custom_interceptors and metrics is None:
|
|
291
|
+
cached = self._chains.get(cache_key)
|
|
292
|
+
if cached is not None:
|
|
293
|
+
return cached
|
|
294
|
+
|
|
295
|
+
logger.debug("Building gRPC interceptor chain for %s -> %s", actual_service_name, chain_target)
|
|
296
|
+
builder = InterceptorChainBuilder()
|
|
297
|
+
|
|
298
|
+
if self._settings:
|
|
299
|
+
builder.with_observability(
|
|
300
|
+
ObservabilityConfig(
|
|
301
|
+
tracing=self._settings.tracing_enabled,
|
|
302
|
+
# A registry-less metrics interceptor records nothing; keep it out of the
|
|
303
|
+
# chain so "no metrics" is visible in the chain instead of silent.
|
|
304
|
+
metrics=self._settings.metrics_enabled and metrics_registry is not None,
|
|
305
|
+
logging=self._settings.logging_enabled,
|
|
306
|
+
metrics_registry=metrics_registry,
|
|
307
|
+
service_name=actual_service_name,
|
|
308
|
+
sensitive_headers=getattr(self._settings, "sensitive_headers", None),
|
|
309
|
+
enable_method_label=getattr(self._settings, "enable_method_label", True),
|
|
310
|
+
success_log_level=getattr(self._settings, "success_log_level", logging.INFO),
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
builder.with_resilience(
|
|
314
|
+
timeout=self._build_timeout_config(self._settings.timeout),
|
|
315
|
+
retry=self._build_retry_config(self._settings.retry, metrics=metrics_registry),
|
|
316
|
+
circuit_breaker=self._build_cb_config(self._settings.circuit_breaker, metrics=metrics_registry),
|
|
317
|
+
deadline_budget=self._build_deadline_budget_config(
|
|
318
|
+
getattr(self._settings, "deadline_budget", None)
|
|
319
|
+
),
|
|
320
|
+
wait_for_ready=self._build_wait_for_ready_config(getattr(self._settings, "wait_for_ready", None)),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
builder.with_custom(custom_interceptors)
|
|
324
|
+
if balancer is not None:
|
|
325
|
+
# Passive outlier detection, innermost: a call that just got UNAVAILABLE is
|
|
326
|
+
# fresher evidence than any probe, and quarantining the target immediately closes
|
|
327
|
+
# the window in which an active checker would keep routing into a dead backend.
|
|
328
|
+
from .interceptors.outlier import AsyncPassiveOutlierInterceptor # noqa: PLC0415
|
|
329
|
+
|
|
330
|
+
builder.with_extra_inner([AsyncPassiveOutlierInterceptor(balancer, chain_target)])
|
|
331
|
+
chain = builder.build()
|
|
332
|
+
if not custom_interceptors and metrics is None:
|
|
333
|
+
self._chains[cache_key] = chain
|
|
334
|
+
return chain
|
|
335
|
+
|
|
336
|
+
return GrpcClient(
|
|
337
|
+
stub_class=stub_class,
|
|
338
|
+
config=config,
|
|
339
|
+
pool=self._pool,
|
|
340
|
+
balancer=balancer,
|
|
341
|
+
interceptor_factory=build_chain,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def _resolve_metrics_registry(self, metrics: GrpcClientMetricsProtocol | None) -> GrpcClientMetricsProtocol | None:
|
|
345
|
+
"""Pick the registry RPC metrics are recorded into.
|
|
346
|
+
|
|
347
|
+
An explicit argument wins over `settings.metrics_registry`, which otherwise only fed pool
|
|
348
|
+
statistics and left `metrics_enabled` clients recording nothing at all.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
metrics: The registry passed to `create_client`, if any.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The registry to use, or None if metrics cannot be recorded.
|
|
355
|
+
"""
|
|
356
|
+
settings_registry = getattr(self._settings, "metrics_registry", None) if self._settings else None
|
|
357
|
+
registry: GrpcClientMetricsProtocol | None = metrics if metrics is not None else settings_registry
|
|
358
|
+
|
|
359
|
+
if registry is None and self._settings and self._settings.metrics_enabled:
|
|
360
|
+
logger.warning(
|
|
361
|
+
"metrics_enabled is set but no metrics registry is available. "
|
|
362
|
+
"Pass 'metrics=' to create_client() or set 'metrics_registry' in settings; "
|
|
363
|
+
"gRPC client metrics are disabled."
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
return registry
|
|
367
|
+
|
|
368
|
+
def _build_config(self, target: str | None) -> GrpcClientConfig:
|
|
369
|
+
"""Build GrpcClientConfig from settings or overrides.
|
|
370
|
+
|
|
371
|
+
Channel construction settings are optional blocks (see `protocols.GrpcChannelExtrasProtocol`)
|
|
372
|
+
and are read with `getattr`, so a settings model that declares none of them still satisfies
|
|
373
|
+
the factory. ``connectivity`` joins them on the same terms: a settings object that carries a
|
|
374
|
+
`config.ConnectivityConfig` under that name has its channels tuned by it, and one that does
|
|
375
|
+
not is left with gRPC's defaults.
|
|
376
|
+
"""
|
|
377
|
+
if self._settings:
|
|
378
|
+
actual_target = target or self._settings.target
|
|
379
|
+
insecure = self._settings.insecure
|
|
380
|
+
credentials = getattr(self._settings, "credentials", None)
|
|
381
|
+
options = getattr(self._settings, "options", None)
|
|
382
|
+
compression = getattr(self._settings, "compression", None)
|
|
383
|
+
connectivity = getattr(self._settings, "connectivity", None)
|
|
384
|
+
else:
|
|
385
|
+
actual_target = target
|
|
386
|
+
insecure = False
|
|
387
|
+
credentials = None
|
|
388
|
+
options = None
|
|
389
|
+
compression = None
|
|
390
|
+
connectivity = None
|
|
391
|
+
|
|
392
|
+
return GrpcClientConfig(
|
|
393
|
+
target=actual_target,
|
|
394
|
+
insecure=insecure,
|
|
395
|
+
credentials=credentials,
|
|
396
|
+
options=options,
|
|
397
|
+
compression=compression,
|
|
398
|
+
connectivity=connectivity,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _build_balancer(self) -> LoadBalancer | None:
|
|
402
|
+
"""Build LoadBalancer from settings targets and balancer strategy."""
|
|
403
|
+
if not self._settings or not self._settings.targets:
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
balancer_config = None
|
|
407
|
+
if self._settings.balancer:
|
|
408
|
+
s = self._settings.balancer
|
|
409
|
+
try:
|
|
410
|
+
strategy = LoadBalancingStrategy(s.strategy)
|
|
411
|
+
except ValueError as e:
|
|
412
|
+
raise ValueError(f"Invalid load balancing strategy: {s.strategy}") from e
|
|
413
|
+
|
|
414
|
+
balancer_config = LoadBalancerConfig(
|
|
415
|
+
strategy=strategy,
|
|
416
|
+
weights=s.weights,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
return create_balancer(
|
|
420
|
+
targets=self._settings.targets,
|
|
421
|
+
config=balancer_config,
|
|
422
|
+
health_checker=self._health_checker,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def _build_timeout_config(self, s: TimeoutSettingsProtocol | None) -> TimeoutConfig | None:
|
|
426
|
+
"""Build TimeoutConfig from settings protocol."""
|
|
427
|
+
if not s:
|
|
428
|
+
return None
|
|
429
|
+
return TimeoutConfig(default=s.default)
|
|
430
|
+
|
|
431
|
+
def _build_retry_config(
|
|
432
|
+
self, s: RetrySettingsProtocol | None, metrics: GrpcClientMetricsProtocol | None = None
|
|
433
|
+
) -> RetryConfig | None:
|
|
434
|
+
"""Build RetryConfig from settings protocol."""
|
|
435
|
+
if not s:
|
|
436
|
+
return None
|
|
437
|
+
return RetryConfig(
|
|
438
|
+
max_attempts=s.max_attempts,
|
|
439
|
+
initial_backoff=s.initial_backoff,
|
|
440
|
+
max_backoff=s.max_backoff,
|
|
441
|
+
backoff_multiplier=s.backoff_multiplier,
|
|
442
|
+
jitter=getattr(s, "jitter", 0.1),
|
|
443
|
+
retryable_codes=getattr(s, "retryable_codes", None),
|
|
444
|
+
retry_streaming=getattr(s, "retry_streaming", False),
|
|
445
|
+
idempotent_methods=getattr(s, "idempotent_methods", None),
|
|
446
|
+
on_retry=getattr(s, "on_retry", None),
|
|
447
|
+
# The registry opts into retry visibility by implementing the extension protocol.
|
|
448
|
+
metrics=metrics if isinstance(metrics, RetryMetricsProtocol) else None,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def _build_wait_for_ready_config(self, s: Any | None) -> WaitForReadyConfig | None:
|
|
452
|
+
"""Build WaitForReadyConfig from an optional settings block (duck-typed)."""
|
|
453
|
+
if not s:
|
|
454
|
+
return None
|
|
455
|
+
return WaitForReadyConfig(
|
|
456
|
+
default=getattr(s, "default", True),
|
|
457
|
+
per_method=getattr(s, "per_method", None) or {},
|
|
458
|
+
require_deadline=getattr(s, "require_deadline", True),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def _build_deadline_budget_config(self, s: Any | None) -> DeadlineBudgetConfig | None:
|
|
462
|
+
"""Build DeadlineBudgetConfig from an optional settings block (duck-typed)."""
|
|
463
|
+
if not s:
|
|
464
|
+
return None
|
|
465
|
+
return DeadlineBudgetConfig(reserve_for_next=getattr(s, "reserve_for_next", 0.0))
|
|
466
|
+
|
|
467
|
+
def _build_cb_config(
|
|
468
|
+
self, s: CircuitBreakerSettingsProtocol | None, metrics: GrpcClientMetricsProtocol | None = None
|
|
469
|
+
) -> CircuitBreakerConfig | None:
|
|
470
|
+
"""Build CircuitBreakerConfig from settings protocol."""
|
|
471
|
+
if not s:
|
|
472
|
+
return None
|
|
473
|
+
return CircuitBreakerConfig(
|
|
474
|
+
fail_threshold=s.fail_threshold,
|
|
475
|
+
recovery_timeout=s.recovery_timeout,
|
|
476
|
+
half_open_max_calls=s.half_open_max_calls,
|
|
477
|
+
max_methods=getattr(s, "max_methods", 1000),
|
|
478
|
+
metrics=metrics,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
__all__ = ["GrpcClientFactory"]
|