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.
@@ -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"]